Compare commits

..

No commits in common. "main" and "fix/split-panel-edit-group-records" have entirely different histories.

131 changed files with 4263 additions and 41756 deletions

View File

@ -1,559 +0,0 @@
# 다국어 지원 컴포넌트 개발 가이드
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
---
## 1. 타입 정의 시 다국어 필드 추가
### 기본 원칙
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
### 단일 텍스트 속성
```typescript
interface MyComponentConfig {
// 기본 텍스트
title?: string;
// 다국어 키 (필수 추가)
titleLangKeyId?: number;
titleLangKey?: string;
// 라벨
label?: string;
labelLangKeyId?: number;
labelLangKey?: string;
// 플레이스홀더
placeholder?: string;
placeholderLangKeyId?: number;
placeholderLangKey?: string;
}
```
### 배열/목록 속성 (컬럼, 탭 등)
```typescript
interface ColumnConfig {
name: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 기타 속성
width?: number;
align?: "left" | "center" | "right";
}
interface TabConfig {
id: string;
label: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
// 탭 제목도 별도로
title?: string;
titleLangKeyId?: number;
titleLangKey?: string;
}
interface MyComponentConfig {
columns?: ColumnConfig[];
tabs?: TabConfig[];
}
```
### 버튼 컴포넌트
```typescript
interface ButtonComponentConfig {
text?: string;
// 다국어 키 (필수 추가)
langKeyId?: number;
langKey?: string;
}
```
### 실제 예시: 분할 패널
```typescript
interface SplitPanelLayoutConfig {
leftPanel?: {
title?: string;
langKeyId?: number; // 좌측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number; // 각 컬럼 다국어
langKey?: string;
}>;
};
rightPanel?: {
title?: string;
langKeyId?: number; // 우측 패널 제목 다국어
langKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
additionalTabs?: Array<{
label: string;
langKeyId?: number; // 탭 라벨 다국어
langKey?: string;
title?: string;
titleLangKeyId?: number; // 탭 제목 다국어
titleLangKey?: string;
columns?: Array<{
name: string;
label: string;
langKeyId?: number;
langKey?: string;
}>;
}>;
};
}
```
---
## 2. 라벨 추출 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractMultilangLabels` 함수에 추가
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
```typescript
// 새 컴포넌트 타입 체크
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 추출
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,-
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 2. 컬럼 추출
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col, index) => {
const colLabel = col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: colLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
if (config?.text) {
addLabel({
id: `${comp.id}_button`,
componentId: `${comp.id}_button`,
label: config.text,
type: "button",
parentType: "my-new-component",
parentLabel: config.text,
langKeyId: config.langKeyId,
langKey: config.langKey,
});
}
}
```
### 추출해야 할 라벨 타입
| 타입 | 설명 | 예시 |
| ------------- | ------------------ | ------------------------ |
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
---
## 3. 매핑 적용 로직 등록
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `applyMultilangMappings` 함수에 추가
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
```typescript
// 새 컴포넌트 매핑 적용
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 1. 제목 매핑
const titleMapping = mappingMap.get(`${comp.id}_title`);
if (titleMapping) {
updated.componentConfig = {
...updated.componentConfig,
titleLangKeyId: titleMapping.keyId,
titleLangKey: titleMapping.langKey,
};
}
// 2. 컬럼 매핑
if (config?.columns && Array.isArray(config.columns)) {
const updatedColumns = config.columns.map((col, index) => {
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
if (colMapping) {
return {
...col,
langKeyId: colMapping.keyId,
langKey: colMapping.langKey,
};
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
};
}
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
const buttonMapping = mappingMap.get(`${comp.id}_button`);
if (buttonMapping) {
updated.componentConfig = {
...updated.componentConfig,
langKeyId: buttonMapping.keyId,
langKey: buttonMapping.langKey,
};
}
}
```
### 주의사항
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
```typescript
// 잘못된 방법 - 이전 업데이트 덮어쓰기
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
// 올바른 방법 - 이전 업데이트 유지
updated.componentConfig = {
...updated.componentConfig,
langKeyId: mapping.keyId,
}; // ✅
updated.componentConfig = {
...updated.componentConfig,
columns: updatedColumns,
}; // ✅
```
---
## 4. 번역 표시 로직 구현
### 파일 위치
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
### Context 사용
```typescript
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
const MyComponent = ({ component }: Props) => {
const { getTranslatedText } = useScreenMultiLang();
const config = component.componentConfig;
// 제목 번역
const displayTitle = config?.titleLangKey
? getTranslatedText(config.titleLangKey, config.title || "")
: config?.title || "";
// 컬럼 헤더 번역
const translatedColumns = config?.columns?.map((col) => ({
...col,
displayLabel: col.langKey
? getTranslatedText(col.langKey, col.label)
: col.label,
}));
// 버튼 텍스트 번역
const buttonText = config?.langKey
? getTranslatedText(config.langKey, config.text || "")
: config?.text || "";
return (
<div>
<h2>{displayTitle}</h2>
<table>
<thead>
<tr>
{translatedColumns?.map((col, idx) => (
<th key={idx}>{col.displayLabel}</th>
))}
</tr>
</thead>
</table>
<button>{buttonText}</button>
</div>
);
};
```
### getTranslatedText 함수
```typescript
// 첫 번째 인자: langKey (다국어 키)
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
const text = getTranslatedText(
"screen.company_1.Sales.OrderList.품목명",
"품목명"
);
```
### 주의사항
- `langKey`가 없으면 원본 텍스트를 표시합니다.
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
---
## 5. ScreenMultiLangContext에 키 수집 로직 추가
### 파일 위치
`frontend/contexts/ScreenMultiLangContext.tsx`
### `collectLangKeys` 함수에 추가
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
```typescript
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
const keys = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 langKey 수집
if (comp.componentType === "my-new-component") {
// 제목
if (config?.titleLangKey) {
keys.add(config.titleLangKey);
}
// 컬럼
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any) => {
if (col.langKey) {
keys.add(col.langKey);
}
});
}
// 버튼
if (config?.langKey) {
keys.add(config.langKey);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return keys;
};
```
---
## 6. MultilangSettingsModal에 표시 로직 추가
### 파일 위치
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
### `extractLabelsFromComponents` 함수에 추가
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
```typescript
// 새 컴포넌트 라벨 추출
if (comp.componentType === "my-new-component") {
const config = comp.componentConfig as MyComponentConfig;
// 제목
if (config?.title) {
addLabel({
id: `${comp.id}_title`,
componentId: `${comp.id}_title`,
label: config.title,
type: "title",
parentType: "my-new-component",
parentLabel: config.title,
langKeyId: config.titleLangKeyId,
langKey: config.titleLangKey,
});
}
// 컬럼
if (config?.columns) {
config.columns.forEach((col, index) => {
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
const tableName = config.tableName;
const displayLabel =
tableName && columnLabelMap[tableName]?.[col.name]
? columnLabelMap[tableName][col.name]
: col.label || col.name;
addLabel({
id: `${comp.id}_col_${index}`,
componentId: `${comp.id}_col_${index}`,
label: displayLabel,
type: "column",
parentType: "my-new-component",
parentLabel: config.title || "새 컴포넌트",
langKeyId: col.langKeyId,
langKey: col.langKey,
});
});
}
}
```
---
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
### 파일 위치
`frontend/lib/utils/multilangLabelExtractor.ts`
### `extractTableNames` 함수에 추가
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
```typescript
const extractTableNames = (comps: ComponentData[]): Set<string> => {
const tableNames = new Set<string>();
const processComponent = (comp: ComponentData) => {
const config = comp.componentConfig;
// 새 컴포넌트의 테이블명 추출
if (comp.componentType === "my-new-component") {
if (config?.tableName) {
tableNames.add(config.tableName);
}
if (config?.selectedTable) {
tableNames.add(config.selectedTable);
}
}
// 자식 컴포넌트 재귀 처리
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(processComponent);
}
};
comps.forEach(processComponent);
return tableNames;
};
```
---
## 8. 체크리스트
새 컴포넌트 개발 시 다음 항목을 확인하세요:
### 타입 정의
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
### 라벨 추출 (multilangLabelExtractor.ts)
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
### 매핑 적용 (multilangLabelExtractor.ts)
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
### 번역 표시 (컴포넌트 파일)
- [ ] `useScreenMultiLang` 훅 사용
- [ ] `getTranslatedText`로 텍스트 번역 적용
### 키 수집 (ScreenMultiLangContext.tsx)
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
### 설정 모달 (MultilangSettingsModal.tsx)
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
---
## 9. 관련 파일 목록
| 파일 | 역할 |
| -------------------------------------------------------------- | ----------------------- |
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
---
## 10. 주의사항
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
- 제목: `${comp.id}_title`
- 컬럼: `${comp.id}_col_${index}`
- 버튼: `${comp.id}_button`
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트

70
PLAN.MD
View File

@ -1,72 +1,4 @@
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정) # 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
### 1. 단일 화면 복제
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
### 2. 그룹(폴더) 전체 복제
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] 정렬 순서(display_order) 유지
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
### 3. 고급 옵션: 이름 일괄 변경
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
- [x] 미리보기 기능
### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
### 6. 테이블 설정 기능 (TableSettingModal)
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 7. 회사 코드 지원 (최고 관리자)
- [x] 대상 회사 선택 가능
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
- `frontend/lib/api/screen.ts` - 화면 API
- `frontend/lib/api/screenGroup.ts` - 그룹 API
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
## 진행 상태
- [완료] 단일 화면 복제 + 새로고침
- [완료] 그룹 전체 복제 (재귀적)
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [완료] 화면 수정 (이름/그룹/역할/순서)
- [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
---
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요 ## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. 현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.

View File

@ -1044,7 +1044,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2372,7 +2371,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3476,7 +3474,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3713,7 +3710,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3931,7 +3927,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4458,7 +4453,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -5669,7 +5663,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -7432,7 +7425,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -8402,6 +8394,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -9290,7 +9283,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -10141,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -10949,7 +10942,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -11055,7 +11047,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -73,7 +73,6 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달 import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
@ -198,7 +197,6 @@ app.use("/api/multilang", multilangRoutes);
app.use("/api/table-management", tableManagementRoutes); app.use("/api/table-management", tableManagementRoutes);
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능 app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
app.use("/api/screen-management", screenManagementRoutes); app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/common-codes", commonCodeRoutes); app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes); app.use("/api/dynamic-form", dynamicFormRoutes);
app.use("/api/files", fileRoutes); app.use("/api/files", fileRoutes);

View File

@ -553,24 +553,10 @@ export const setUserLocale = async (
const { locale } = req.body; const { locale } = req.body;
if (!locale) { if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "로케일이 필요합니다.", message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
});
return;
}
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
success: false,
message: `유효하지 않은 로케일입니다: ${locale}`,
}); });
return; return;
} }
@ -1179,33 +1165,6 @@ export async function saveMenu(
logger.info("메뉴 저장 성공", { savedMenu }); logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: "메뉴가 성공적으로 저장되었습니다.", message: "메뉴가 성공적으로 저장되었습니다.",
@ -1417,75 +1376,6 @@ export async function updateMenu(
} }
} }
/**
* ID를
*/
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
const allIds: number[] = [];
// 직접 자식 메뉴들 조회
const children = await query<any>(
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
[parentObjid]
);
for (const child of children) {
allIds.push(child.objid);
// 자식의 자식들도 재귀적으로 수집
const grandChildren = await collectAllChildMenuIds(child.objid);
allIds.push(...grandChildren);
}
return allIds;
}
/**
*
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 3. code_info에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
// 7. screen_groups에서 menu_objid를 NULL로 설정
await query(
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
}
/** /**
* *
*/ */
@ -1512,7 +1402,7 @@ export async function deleteMenu(
// 삭제하려는 메뉴 조회 // 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>( const currentMenu = await queryOne<any>(
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, `SELECT objid, company_code FROM menu_info WHERE objid = $1`,
[Number(menuId)] [Number(menuId)]
); );
@ -1547,50 +1437,67 @@ export async function deleteMenu(
} }
} }
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
const menuObjid = Number(menuId); const menuObjid = Number(menuId);
// 하위 메뉴들 재귀적으로 수집 // 1. category_column_mapping에서 menu_objid를 NULL로 설정
const childMenuIds = await collectAllChildMenuIds(menuObjid); await query(
const allMenuIdsToDelete = [menuObjid, ...childMenuIds]; `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, { // 2. code_category에서 menu_objid를 NULL로 설정
menuName: currentMenu.menu_name_kor, await query(
totalCount: allMenuIdsToDelete.length, `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
childMenuIds, [menuObjid]
}); );
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
for (const objid of reversedIds) { // 3. code_info에서 menu_objid를 NULL로 설정
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]); await query(
} `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules에서 menu_objid를 NULL로 설정
await query(
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
logger.info("메뉴 삭제 성공", { logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor, // Raw Query를 사용한 메뉴 삭제
totalDeleted: allMenuIdsToDelete.length, const [deletedMenu] = await query<any>(
}); `DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid]
);
logger.info("메뉴 삭제 성공", { deletedMenu });
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`, message: "메뉴가 성공적으로 삭제되었습니다.",
data: { data: {
objid: menuObjid.toString(), objid: deletedMenu.objid.toString(),
menuNameKor: currentMenu.menu_name_kor, menuNameKor: deletedMenu.menu_name_kor,
deletedCount: allMenuIdsToDelete.length, menuNameEng: deletedMenu.menu_name_eng,
deletedChildCount: childMenuIds.length, menuUrl: deletedMenu.menu_url,
menuDesc: deletedMenu.menu_desc,
status: deletedMenu.status,
writer: deletedMenu.writer,
regdate: new Date(deletedMenu.regdate).toISOString(),
}, },
}; };
@ -1675,49 +1582,18 @@ export async function deleteMenusBatch(
} }
} }
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
for (const menuId of menuIds) {
const objid = Number(menuId);
allMenuIdsToDelete.add(objid);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(objid);
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
}
const allIdsArray = Array.from(allMenuIdsToDelete);
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}`, {
selectedMenuIds: menuIds,
totalWithChildren: allIdsArray.length,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allIdsArray) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
totalCleaned: allIdsArray.length
});
// Raw Query를 사용한 메뉴 일괄 삭제 // Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0; let deletedCount = 0;
let failedCount = 0; let failedCount = 0;
const deletedMenus: any[] = []; const deletedMenus: any[] = [];
const failedMenuIds: string[] = []; const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도 // 각 메뉴 ID에 대해 삭제 시도
for (const menuObjid of reversedIds) { for (const menuId of menuIds) {
try { try {
const result = await query<any>( const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`, `DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid] [Number(menuId)]
); );
if (result.length > 0) { if (result.length > 0) {
@ -1728,20 +1604,20 @@ export async function deleteMenusBatch(
}); });
} else { } else {
failedCount++; failedCount++;
failedMenuIds.push(String(menuObjid)); failedMenuIds.push(menuId);
} }
} catch (error) { } catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error); logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
failedCount++; failedCount++;
failedMenuIds.push(String(menuObjid)); failedMenuIds.push(menuId);
} }
} }
logger.info("메뉴 일괄 삭제 완료", { logger.info("메뉴 일괄 삭제 완료", {
requested: menuIds.length, total: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount, deletedCount,
failedCount, failedCount,
deletedMenus,
failedMenuIds, failedMenuIds,
}); });
@ -2773,24 +2649,6 @@ export const createCompany = async (
}); });
} }
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", { logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code, companyCode: createdCompany.company_code,
companyName: createdCompany.company_name, companyName: createdCompany.company_name,
@ -3200,23 +3058,6 @@ export const updateProfile = async (
} }
if (locale !== undefined) { if (locale !== undefined) {
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
result: false,
error: {
code: "INVALID_LOCALE",
details: `유효하지 않은 로케일입니다: ${locale}`,
},
});
return;
}
updateFields.push(`locale = $${paramIndex}`); updateFields.push(`locale = $${paramIndex}`);
updateValues.push(locale); updateValues.push(locale);
paramIndex++; paramIndex++;

View File

@ -231,7 +231,7 @@ export const deleteFormData = async (
try { try {
const { id } = req.params; const { id } = req.params;
const { companyCode, userId } = req.user as any; const { companyCode, userId } = req.user as any;
const { tableName, screenId } = req.body; const { tableName } = req.body;
if (!tableName) { if (!tableName) {
return res.status(400).json({ return res.status(400).json({
@ -240,16 +240,7 @@ export const deleteFormData = async (
}); });
} }
// screenId를 숫자로 변환 (문자열로 전달될 수 있음) await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
await dynamicFormService.deleteFormData(
id,
tableName,
companyCode,
userId,
parsedScreenId // screenId 추가 (제어관리 실행용)
);
res.json({ res.json({
success: true, success: true,

View File

@ -30,6 +30,7 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터 autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열) dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외 excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams ...otherParams
} = req.query; } = req.query;
@ -66,23 +67,11 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode"; const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField]; const userValue = ((req as any).user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) if (userValue) {
let finalCompanyCode = userValue; searchConditions[filterColumn] = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn, filterColumn,
finalCompanyCode, userValue,
tableName, tableName,
}); });
} }
@ -151,6 +140,24 @@ export class EntityJoinController {
} }
} }
// 🆕 중복 제거 설정 처리
let parsedDeduplication: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
} | undefined = undefined;
if (deduplication) {
try {
parsedDeduplication =
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
} catch (error) {
logger.warn("중복 제거 설정 파싱 오류:", error);
parsedDeduplication = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins( const result = await tableManagementService.getTableDataWithEntityJoins(
tableName, tableName,
{ {
@ -168,13 +175,26 @@ export class EntityJoinController {
screenEntityConfigs: parsedScreenEntityConfigs, screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달 dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달 excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
} }
); );
// 🆕 중복 제거 처리 (결과 데이터에 적용)
let finalData = result;
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
const originalCount = result.data.length;
finalData = {
...result,
data: this.deduplicateData(result.data, parsedDeduplication),
};
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}`);
}
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: "Entity 조인 데이터 조회 성공", message: "Entity 조인 데이터 조회 성공",
data: result, data: finalData,
}); });
} catch (error) { } catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error); logger.error("Entity 조인 데이터 조회 실패", error);
@ -549,6 +569,98 @@ export class EntityJoinController {
}); });
} }
} }
/**
* ( )
*/
private deduplicateData(
data: any[],
config: {
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}
): any[] {
if (!data || data.length === 0) return data;
// 그룹별로 데이터 분류
const groups: Record<string, any[]> = {};
for (const row of data) {
const groupKey = row[config.groupByColumn];
if (groupKey === undefined || groupKey === null) continue;
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(row);
}
// 각 그룹에서 하나의 행만 선택
const result: any[] = [];
for (const [groupKey, rows] of Object.entries(groups)) {
if (rows.length === 0) continue;
let selectedRow: any;
switch (config.keepStrategy) {
case "latest":
// 정렬 컬럼 기준 최신 (가장 큰 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal > bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "earliest":
// 정렬 컬럼 기준 최초 (가장 작은 값)
if (config.sortColumn) {
rows.sort((a, b) => {
const aVal = a[config.sortColumn!];
const bVal = b[config.sortColumn!];
if (aVal === bVal) return 0;
if (aVal < bVal) return -1;
return 1;
});
}
selectedRow = rows[0];
break;
case "base_price":
// base_price가 true인 행 선택
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
break;
case "current_date":
// 오늘 날짜 기준 유효 기간 내 행 선택
const today = new Date().toISOString().split("T")[0];
selectedRow = rows.find((r) => {
const startDate = r.start_date;
const endDate = r.end_date;
if (!startDate) return true;
if (startDate <= today && (!endDate || endDate >= today)) return true;
return false;
}) || rows[0];
break;
default:
selectedRow = rows[0];
}
if (selectedRow) {
result.push(selectedRow);
}
}
return result;
}
} }
export const entityJoinController = new EntityJoinController(); export const entityJoinController = new EntityJoinController();

View File

@ -10,10 +10,7 @@ import {
SaveLangTextsRequest, SaveLangTextsRequest,
GetUserTextParams, GetUserTextParams,
BatchTranslationRequest, BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse, ApiResponse,
LangCategory,
} from "../types/multilang"; } from "../types/multilang";
/** /**
@ -190,7 +187,7 @@ export const getLangKeys = async (
res: Response res: Response
): Promise<void> => { ): Promise<void> => {
try { try {
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; const { companyCode, menuCode, keyType, searchText } = req.query;
logger.info("다국어 키 목록 조회 요청", { logger.info("다국어 키 목록 조회 요청", {
query: req.query, query: req.query,
user: req.user, user: req.user,
@ -202,7 +199,6 @@ export const getLangKeys = async (
menuCode: menuCode as string, menuCode: menuCode as string,
keyType: keyType as string, keyType: keyType as string,
searchText: searchText as string, searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
}); });
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
@ -634,391 +630,6 @@ export const deleteLanguage = async (
} }
}; };
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
* GET /api/multilang/categories
* API ( )
*/
export const getCategories = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("카테고리 목록 조회 요청", { user: req.user });
const multiLangService = new MultiLangService();
const categories = await multiLangService.getCategories();
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 목록 조회 성공",
data: categories,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId
* API
*/
export const getCategoryById = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const category = await multiLangService.getCategoryById(parseInt(categoryId));
if (!category) {
res.status(404).json({
success: false,
message: "카테고리를 찾을 수 없습니다.",
error: {
code: "CATEGORY_NOT_FOUND",
details: `Category ID ${categoryId} not found`,
},
});
return;
}
const response: ApiResponse<LangCategory> = {
success: true,
message: "카테고리 상세 조회 성공",
data: category,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 상세 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_DETAIL_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/categories/:categoryId/path
* API ( )
*/
export const getCategoryPath = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId } = req.params;
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
const multiLangService = new MultiLangService();
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
const response: ApiResponse<LangCategory[]> = {
success: true,
message: "카테고리 경로 조회 성공",
data: path,
};
res.status(200).json(response);
} catch (error) {
logger.error("카테고리 경로 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
error: {
code: "CATEGORY_PATH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
// =====================================================
// 자동 생성 및 오버라이드 관련 API
// =====================================================
/**
* POST /api/multilang/keys/generate
* API
*/
export const generateKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const generateData: GenerateKeyRequest = req.body;
logger.info("키 자동 생성 요청", { generateData, user: req.user });
// 필수 입력값 검증
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
res.status(400).json({
success: false,
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode, categoryId, and keyMeaning are required",
},
});
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Only super admin can create common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 키만 생성 가능
if (generateData.companyCode !== "*" &&
req.user?.companyCode !== "*" &&
generateData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.generateKey({
...generateData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("키 자동 생성 실패:", error);
res.status(500).json({
success: false,
message: "키 자동 생성 중 오류가 발생했습니다.",
error: {
code: "KEY_GENERATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/preview
* API
*/
export const previewKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { categoryId, keyMeaning, companyCode } = req.body;
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
if (!categoryId || !keyMeaning || !companyCode) {
res.status(400).json({
success: false,
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "categoryId, keyMeaning, and companyCode are required",
},
});
return;
}
const multiLangService = new MultiLangService();
const preview = await multiLangService.previewGeneratedKey(
parseInt(categoryId),
keyMeaning,
companyCode
);
const response: ApiResponse<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> = {
success: true,
message: "키 미리보기 성공",
data: preview,
};
res.status(200).json(response);
} catch (error) {
logger.error("키 미리보기 실패:", error);
res.status(500).json({
success: false,
message: "키 미리보기 중 오류가 발생했습니다.",
error: {
code: "KEY_PREVIEW_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* POST /api/multilang/keys/override
* API
*/
export const createOverrideKey = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const overrideData: CreateOverrideKeyRequest = req.body;
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
// 필수 입력값 검증
if (!overrideData.companyCode || !overrideData.baseKeyId) {
res.status(400).json({
success: false,
message: "회사 코드와 원본 키 ID는 필수입니다.",
error: {
code: "MISSING_REQUIRED_FIELDS",
details: "companyCode and baseKeyId are required",
},
});
return;
}
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
if (overrideData.companyCode === "*") {
res.status(400).json({
success: false,
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
error: {
code: "INVALID_OVERRIDE",
details: "Cannot create override for common keys",
},
});
return;
}
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
if (req.user?.companyCode !== "*" &&
overrideData.companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot create override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createOverrideKey({
...overrideData,
createdBy: req.user?.userId || "system",
});
const response: ApiResponse<number> = {
success: true,
message: "오버라이드 키가 성공적으로 생성되었습니다.",
data: keyId,
};
res.status(201).json(response);
} catch (error) {
logger.error("오버라이드 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEY_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/**
* GET /api/multilang/keys/overrides/:companyCode
* API
*/
export const getOverrideKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
error: {
code: "PERMISSION_DENIED",
details: "Cannot view override keys for other companies",
},
});
return;
}
const multiLangService = new MultiLangService();
const keys = await multiLangService.getOverrideKeys(companyCode);
const response: ApiResponse<any[]> = {
success: true,
message: "오버라이드 키 목록 조회 성공",
data: keys,
};
res.status(200).json(response);
} catch (error) {
logger.error("오버라이드 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
error: {
code: "OVERRIDE_KEYS_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};
/** /**
* POST /api/multilang/batch * POST /api/multilang/batch
* API * API
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
}); });
} }
}; };
/**
* POST /api/multilang/screen-labels
* API
*/
export const generateScreenLabelKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { screenId, menuObjId, labels } = req.body;
logger.info("화면 라벨 다국어 키 생성 요청", {
screenId,
menuObjId,
labelCount: labels?.length,
user: req.user,
});
// 필수 파라미터 검증
if (!screenId) {
res.status(400).json({
success: false,
message: "screenId는 필수입니다.",
error: { code: "MISSING_SCREEN_ID" },
});
return;
}
if (!labels || !Array.isArray(labels) || labels.length === 0) {
res.status(400).json({
success: false,
message: "labels 배열이 필요합니다.",
error: { code: "MISSING_LABELS" },
});
return;
}
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
const { queryOne } = await import("../database/db");
const screenInfo = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
const multiLangService = new MultiLangService();
const results = await multiLangService.generateScreenLabelKeys({
screenId: Number(screenId),
companyCode,
companyName,
menuObjId,
labels,
});
const response: ApiResponse<typeof results> = {
success: true,
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
data: results,
};
res.status(200).json(response);
} catch (error) {
logger.error("화면 라벨 다국어 키 생성 실패:", error);
res.status(500).json({
success: false,
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
};

File diff suppressed because it is too large Load Diff

View File

@ -775,25 +775,18 @@ export async function getTableData(
const userField = autoFilter?.userField || "companyCode"; const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField]; const userValue = (req.user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용) // 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
let finalCompanyCode = userValue; if (userValue && userValue !== "*") {
if (autoFilter?.companyCodeOverride && userValue === "*") { enhancedSearch[filterColumn] = userValue;
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", { logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn, filterColumn,
userField, userField,
userValue: finalCompanyCode, userValue,
tableName,
});
} else if (userValue === "*") {
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
tableName, tableName,
}); });
} else { } else {
@ -804,6 +797,9 @@ export async function getTableData(
} }
} }
// 🆕 최종 검색 조건 로그
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
// 데이터 조회 // 데이터 조회
const result = await tableManagementService.getTableData(tableName, { const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page), page: parseInt(page),
@ -905,13 +901,23 @@ export async function addTableData(
} }
// 데이터 추가 // 데이터 추가
await tableManagementService.addTableData(tableName, data); const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}`);
const response: ApiResponse<null> = { // 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
success: true, success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.", message: result.skippedColumns.length > 0
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
: "테이블 데이터를 성공적으로 추가했습니다.",
data: {
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
savedColumns: result.savedColumns,
},
}; };
res.status(201).json(response); res.status(201).json(response);
@ -2180,8 +2186,11 @@ export async function multiTableSave(
} }
/** /**
* *
* column_labels의 entity/category * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
*
* column_labels에서 /
* .
*/ */
export async function getTableEntityRelations( export async function getTableEntityRelations(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -2190,93 +2199,53 @@ export async function getTableEntityRelations(
try { try {
const { leftTable, rightTable } = req.query; const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
if (!leftTable || !rightTable) { if (!leftTable || !rightTable) {
res.status(400).json({ const response: ApiResponse<null> = {
success: false, success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.", message: "leftTable과 rightTable 파라미터가 필요합니다.",
}); error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return; return;
} }
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable }); const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
// 두 테이블의 컬럼 라벨 정보 조회 logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
web_type,
detail_settings
FROM column_labels
WHERE table_name IN ($1, $2)
AND web_type IN ('entity', 'category')
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]); const response: ApiResponse<any> = {
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true, success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: { data: {
leftTable, leftTable: String(leftTable),
rightTable, rightTable: String(rightTable),
relations, relations,
}, },
}); };
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error); res.status(200).json(response);
res.status(500).json({ } catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false, success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.", message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
error: error.message, error: {
}); code: "ENTITY_RELATIONS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
} }
} }

View File

@ -56,4 +56,3 @@ export default router;

View File

@ -52,4 +52,3 @@ export default router;

View File

@ -68,4 +68,3 @@ export default router;

View File

@ -56,4 +56,3 @@ export default router;

View File

@ -1,262 +1,10 @@
import express from "express"; import express from "express";
import { dataService } from "../services/dataService"; import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
const router = express.Router(); const router = express.Router();
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* -
* GET /api/data/master-detail/relation/:screenId
*/
router.get(
"/master-detail/relation/:screenId",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId } = req.params;
if (!screenId || isNaN(parseInt(screenId))) {
return res.status(400).json({
success: false,
message: "유효한 screenId가 필요합니다.",
});
}
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.json({
success: true,
data: null,
message: "마스터-디테일 구조가 아닙니다.",
});
}
console.log(`✅ 마스터-디테일 관계 발견:`, {
masterTable: relation.masterTable,
detailTable: relation.detailTable,
joinKey: relation.masterKeyColumn,
});
return res.json({
success: true,
data: relation,
});
} catch (error: any) {
console.error("마스터-디테일 관계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/download
*/
router.post(
"/master-detail/download",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, filters } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!screenId) {
return res.status(400).json({
success: false,
message: "screenId가 필요합니다.",
});
}
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. JOIN 데이터 조회
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
return res.json({
success: true,
data,
});
} catch (error: any) {
console.error("마스터-디테일 다운로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* POST /api/data/master-detail/upload
*/
router.post(
"/master-detail/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, data } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!screenId || !data || !Array.isArray(data)) {
return res.status(400).json({
success: false,
message: "screenId와 data 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
);
if (!relation) {
return res.status(400).json({
success: false,
message: "마스터-디테일 구조가 아닙니다.",
});
}
// 2. 데이터 업로드
const result = await masterDetailExcelService.uploadJoinedData(
relation,
data,
companyCode,
userId
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
/**
* -
* - UI에서
* -
* -
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
"/master-detail/upload-simple",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!screenId || !detailData || !Array.isArray(detailData)) {
return res.status(400).json({
success: false,
message: "screenId와 detailData 배열이 필요합니다.",
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
parseInt(screenId),
detailData,
masterFieldValues || {},
numberingRuleId,
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================
/** /**
* API ( ) * API ( )
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=... * GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...

View File

@ -21,20 +21,6 @@ import {
getUserText, getUserText,
getLangText, getLangText,
getBatchTranslations, getBatchTranslations,
// 카테고리 관리 API
getCategories,
getCategoryById,
getCategoryPath,
// 자동 생성 및 오버라이드 API
generateKey,
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController"; } from "../controllers/multilangController";
const router = express.Router(); const router = express.Router();
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회 router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회 router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
// 카테고리 관리 API
router.get("/categories", getCategories); // 카테고리 트리 조회
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
// 자동 생성 및 오버라이드 API
router.post("/keys/generate", generateKey); // 키 자동 생성
router.post("/keys/preview", previewKey); // 키 미리보기
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
// 화면 라벨 다국어 자동 생성 API
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
export default router; export default router;

View File

@ -1,111 +0,0 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
// 화면 그룹
getScreenGroups,
getScreenGroup,
createScreenGroup,
updateScreenGroup,
deleteScreenGroup,
// 화면-그룹 연결
addScreenToGroup,
removeScreenFromGroup,
updateScreenInGroup,
// 필드 조인
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
// 데이터 흐름
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
// 화면-테이블 관계
getTableRelations,
createTableRelation,
updateTableRelation,
deleteTableRelation,
// 화면 레이아웃 요약
getScreenLayoutSummary,
getMultipleScreenLayoutSummary,
// 화면 서브 테이블 관계
getScreenSubTables,
// 메뉴-화면그룹 동기화
syncScreenGroupsToMenuController,
syncMenuToScreenGroupsController,
getSyncStatusController,
syncAllCompaniesController,
} from "../controllers/screenGroupController";
const router = Router();
// 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
// ============================================================
// 화면 그룹 (screen_groups)
// ============================================================
router.get("/groups", getScreenGroups);
router.get("/groups/:id", getScreenGroup);
router.post("/groups", createScreenGroup);
router.put("/groups/:id", updateScreenGroup);
router.delete("/groups/:id", deleteScreenGroup);
// ============================================================
// 화면-그룹 연결 (screen_group_screens)
// ============================================================
router.post("/group-screens", addScreenToGroup);
router.put("/group-screens/:id", updateScreenInGroup);
router.delete("/group-screens/:id", removeScreenFromGroup);
// ============================================================
// 필드 조인 설정 (screen_field_joins)
// ============================================================
router.get("/field-joins", getFieldJoins);
router.post("/field-joins", createFieldJoin);
router.put("/field-joins/:id", updateFieldJoin);
router.delete("/field-joins/:id", deleteFieldJoin);
// ============================================================
// 데이터 흐름 (screen_data_flows)
// ============================================================
router.get("/data-flows", getDataFlows);
router.post("/data-flows", createDataFlow);
router.put("/data-flows/:id", updateDataFlow);
router.delete("/data-flows/:id", deleteDataFlow);
// ============================================================
// 화면-테이블 관계 (screen_table_relations)
// ============================================================
router.get("/table-relations", getTableRelations);
router.post("/table-relations", createTableRelation);
router.put("/table-relations/:id", updateTableRelation);
router.delete("/table-relations/:id", deleteTableRelation);
// ============================================================
// 화면 레이아웃 요약 (미리보기용)
// ============================================================
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
// ============================================================
// 화면 서브 테이블 관계 (조인/참조 테이블)
// ============================================================
router.post("/sub-tables/batch", getScreenSubTables);
// ============================================================
// 메뉴-화면그룹 동기화
// ============================================================
// 동기화 상태 조회
router.get("/sync/status", getSyncStatusController);
// 화면관리 → 메뉴 동기화
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
// 메뉴 → 화면관리 동기화
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
// 전체 회사 동기화 (최고 관리자만)
router.post("/sync/all", syncAllCompaniesController);
export default router;

View File

@ -254,10 +254,7 @@ class DataService {
key !== "limit" && key !== "limit" &&
key !== "offset" && key !== "offset" &&
key !== "orderBy" && key !== "orderBy" &&
key !== "userLang" && key !== "userLang"
key !== "page" &&
key !== "pageSize" &&
key !== "size"
) { ) {
// 컬럼명 검증 (SQL 인젝션 방지) // 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {

View File

@ -1192,18 +1192,12 @@ export class DynamicFormService {
/** /**
* ( ) * ( )
* @param id ID
* @param tableName
* @param companyCode
* @param userId ID
* @param screenId ID ( , )
*/ */
async deleteFormData( async deleteFormData(
id: string | number, id: string | number,
tableName: string, tableName: string,
companyCode?: string, companyCode?: string,
userId?: string, userId?: string
screenId?: number
): Promise<void> { ): Promise<void> {
try { try {
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
@ -1316,19 +1310,14 @@ export class DynamicFormService {
const recordCompanyCode = const recordCompanyCode =
deletedRecord?.company_code || companyCode || "*"; deletedRecord?.company_code || companyCode || "*";
// screenId가 전달되지 않으면 제어관리를 실행하지 않음 await this.executeDataflowControlIfConfigured(
if (screenId && screenId > 0) { 0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
await this.executeDataflowControlIfConfigured( tableName,
screenId, deletedRecord,
tableName, "delete",
deletedRecord, userId || "system",
"delete", recordCompanyCode
userId || "system", );
recordCompanyCode
);
} else {
console.log(" screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
}
} }
} catch (controlError) { } catch (controlError) {
console.error("⚠️ 제어관리 실행 오류:", controlError); console.error("⚠️ 제어관리 실행 오류:", controlError);
@ -1673,16 +1662,10 @@ export class DynamicFormService {
!!properties?.webTypeConfig?.dataflowConfig?.flowControls, !!properties?.webTypeConfig?.dataflowConfig?.flowControls,
}); });
// 버튼 컴포넌트이고 제어관리가 활성화된 경우 // 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
const buttonActionType = properties?.componentConfig?.action?.type;
const isMatchingAction =
(triggerType === "delete" && buttonActionType === "delete") ||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
if ( if (
properties?.componentType === "button-primary" && properties?.componentType === "button-primary" &&
isMatchingAction && properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true properties?.webTypeConfig?.enableDataflowControl === true
) { ) {
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;

View File

@ -1,908 +0,0 @@
/**
* -
*
* -
* / JOIN .
*/
import { query, queryOne, transaction, getPool } from "../database/db";
import { logger } from "../utils/logger";
// ================================
// 인터페이스 정의
// ================================
/**
* -
*/
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
masterColumns: ColumnInfo[];
detailColumns: ColumnInfo[];
}
/**
*
*/
export interface ColumnInfo {
name: string;
label: string;
inputType: string;
isFromMaster: boolean;
}
/**
*
*/
export interface SplitPanelConfig {
leftPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
};
rightPanel: {
tableName: string;
columns: Array<{ name: string; label: string; width?: number }>;
relation?: {
type: string;
foreignKey?: string;
leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
};
};
}
/**
*
*/
export interface ExcelDownloadData {
headers: string[]; // 컬럼 라벨들
columns: string[]; // 컬럼명들
data: Record<string, any>[];
masterColumns: string[]; // 마스터 컬럼 목록
detailColumns: string[]; // 디테일 컬럼 목록
joinKey: string; // 조인 키
}
/**
*
*/
export interface ExcelUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// ================================
// 서비스 클래스
// ================================
class MasterDetailExcelService {
/**
* ID로
*/
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
try {
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
const result = await queryOne<any>(
`SELECT properties->>'componentConfig' as config
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties->>'componentType' = 'split-panel-layout'
LIMIT 1`,
[screenId]
);
if (!result || !result.config) {
logger.info(`분할 패널 없음: screenId=${screenId}`);
return null;
}
const config = typeof result.config === "string"
? JSON.parse(result.config)
: result.config;
logger.info(`분할 패널 설정 발견:`, {
leftTable: config.leftPanel?.tableName,
rightTable: config.rightPanel?.tableName,
relation: config.rightPanel?.relation,
});
return {
leftPanel: config.leftPanel,
rightPanel: config.rightPanel,
};
} catch (error: any) {
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
return null;
}
}
/**
* column_labels에서 Entity
*
*/
async getEntityRelation(
detailTable: string,
masterTable: string
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
try {
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
const result = await queryOne<any>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[detailTable, masterTable]
);
if (!result) {
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
return null;
}
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
return {
detailFkColumn: result.column_name,
masterKeyColumn: result.reference_column,
};
} catch (error: any) {
logger.error(`Entity 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
*
*/
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
try {
const result = await query<any>(
`SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
const labelMap = new Map<string, string>();
for (const row of result) {
labelMap.set(row.column_name, row.column_label || row.column_name);
}
return labelMap;
} catch (error: any) {
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
return new Map();
}
}
/**
* -
*/
async getMasterDetailRelation(
screenId: number
): Promise<MasterDetailRelation | null> {
try {
// 1. 분할 패널 설정 조회
const splitPanel = await this.getSplitPanelConfig(screenId);
if (!splitPanel) {
return null;
}
const masterTable = splitPanel.leftPanel.tableName;
const detailTable = splitPanel.rightPanel.tableName;
if (!masterTable || !detailTable) {
logger.warn("마스터 또는 디테일 테이블명 없음");
return null;
}
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let masterKeyColumn: string | undefined;
let detailFkColumn: string | undefined;
const relationKeys = splitPanel.rightPanel.relation?.keys;
if (relationKeys && relationKeys.length > 0) {
// keys 배열에서 첫 번째 키 사용
masterKeyColumn = relationKeys[0].leftColumn;
detailFkColumn = relationKeys[0].rightColumn;
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
} else {
// 하위 호환성: 기존 leftColumn/foreignKey 사용
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) {
masterKeyColumn = entityRelation.masterKeyColumn;
detailFkColumn = entityRelation.detailFkColumn;
}
}
if (!masterKeyColumn || !detailFkColumn) {
logger.warn("조인 키 정보를 찾을 수 없음");
return null;
}
// 4. 컬럼 라벨 정보 조회
const masterLabels = await this.getColumnLabels(masterTable);
const detailLabels = await this.getColumnLabels(detailTable);
// 5. 마스터 컬럼 정보 구성
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
name: col.name,
label: masterLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: true,
}));
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
.map(col => ({
name: col.name,
label: detailLabels.get(col.name) || col.label || col.name,
inputType: "text",
isFromMaster: false,
}));
logger.info(`마스터-디테일 관계 구성 완료:`, {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumnCount: masterColumns.length,
detailColumnCount: detailColumns.length,
});
return {
masterTable,
detailTable,
masterKeyColumn,
detailFkColumn,
masterColumns,
detailColumns,
};
} catch (error: any) {
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
return null;
}
}
/**
* - JOIN ( )
*/
async getJoinedData(
relation: MasterDetailRelation,
companyCode: string,
filters?: Record<string, any>
): Promise<ExcelDownloadData> {
try {
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 조인 컬럼과 일반 컬럼 분리
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
const entityJoins: Array<{
refTable: string;
refColumn: string;
sourceColumn: string;
alias: string;
displayColumn: string;
}> = [];
// SELECT 절 구성
const selectParts: string[] = [];
let aliasIndex = 0;
// 마스터 컬럼 처리
for (const col of masterColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
// FK를 못 찾으면 NULL로 처리
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`m."${col.name}"`);
}
}
// 디테일 컬럼 처리
for (const col of detailColumns) {
if (col.name.includes(".")) {
// 조인 컬럼: 테이블명.컬럼명
const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`;
// column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) {
entityJoins.push({
refTable,
refColumn: fkColumn.referenceColumn,
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
selectParts.push(`NULL AS "${col.name}"`);
}
} else {
// 일반 컬럼
selectParts.push(`d."${col.name}"`);
}
}
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (최고 관리자 제외)
if (companyCode && companyCode !== "*") {
whereConditions.push(`m.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// 추가 필터 적용
if (filters) {
for (const [key, value] of Object.entries(filters)) {
if (value !== undefined && value !== null && value !== "") {
// 조인 컬럼인지 확인
if (key.includes(".")) continue;
// 마스터 테이블 컬럼인지 확인
const isMasterCol = masterColumns.some(c => c.name === key);
const tableAlias = isMasterCol ? "m" : "d";
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
params.push(value);
paramIndex++;
}
}
}
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// JOIN 쿼리 실행
const sql = `
SELECT ${selectClause}
FROM "${masterTable}" m
LEFT JOIN "${detailTable}" d
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
`;
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
const data = await query<any>(sql, params);
// 헤더 및 컬럼 정보 구성
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}`);
return {
headers,
columns,
data,
masterColumns: masterColumns.map(c => c.name),
detailColumns: detailColumns.map(c => c.name),
joinKey: masterKeyColumn,
};
} catch (error: any) {
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
throw error;
}
}
/**
* FK
*/
private async findForeignKeyColumn(
sourceTable: string,
referenceTable: string
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
try {
const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column
FROM column_labels
WHERE table_name = $1
AND reference_table = $2
AND input_type = 'entity'
LIMIT 1`,
[sourceTable, referenceTable]
);
if (result.length > 0) {
return {
sourceColumn: result[0].column_name,
referenceColumn: result[0].reference_column,
};
}
return null;
} catch (error) {
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
return null;
}
}
/**
* - ( )
*
* :
* 1.
* 2. UPSERT
* 3.
* 4. INSERT
*/
async uploadJoinedData(
relation: MasterDetailRelation,
data: Record<string, any>[],
companyCode: string,
userId?: string
): Promise<ExcelUploadResult> {
const result: ExcelUploadResult = {
success: false,
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailDeleted: 0,
errors: [],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 1. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
}
groupedData.get(masterKey)!.push(row);
}
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
// 2. 각 그룹 처리
for (const [masterKey, rows] of groupedData.entries()) {
try {
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
const masterData: Record<string, any> = {};
for (const col of masterColumns) {
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가
masterData.company_code = companyCode;
if (userId) {
masterData.writer = userId;
}
// 2b. 마스터 UPSERT
const existingMaster = await client.query(
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
// UPDATE
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}, updated_date = NOW()
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// INSERT
const insertCols = Object.keys(masterData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.masterInserted++;
}
// 2c. 기존 디테일 삭제
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
// 2d. 새 디테일 INSERT
for (const row of rows) {
const detailData: Record<string, any> = {};
// FK 컬럼 추가
detailData[detailFkColumn] = masterKey;
detailData.company_code = companyCode;
if (userId) {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const insertCols = Object.keys(detailData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => detailData[k]);
await client.query(
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.detailInserted++;
}
} catch (error: any) {
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
}
}
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
logger.info(`마스터-디테일 업로드 완료:`, {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
errors: result.errors.length,
});
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* -
*
* UI에서 ,
*
*
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param companyCode
* @param userId ID
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
*/
async uploadSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId: string | undefined,
companyCode: string,
userId: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<{
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
}> {
const result: {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string;
errors: string[];
controlResult?: any;
} = {
success: false,
masterInserted: 0,
detailInserted: 0,
generatedKey: "",
errors: [] as string[],
};
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 마스터-디테일 관계 정보 조회
const relation = await this.getMasterDetailRelation(screenId);
if (!relation) {
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
}
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
// 2. 채번 처리
let generatedKey: string;
if (numberingRuleId) {
// 채번 규칙으로 키 생성
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
} else {
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
generatedKey = masterFieldValues[masterKeyColumn];
if (!generatedKey) {
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
}
}
result.generatedKey = generatedKey;
logger.info(`채번 결과: ${generatedKey}`);
// 3. 마스터 레코드 생성
const masterData: Record<string, any> = {
...masterFieldValues,
[masterKeyColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 마스터 컬럼명 목록 구성
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
const masterValues = masterCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
masterValues
);
result.masterInserted = 1;
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
const insertedDetailRows: Record<string, any>[] = [];
for (const row of detailData) {
try {
const detailRowData: Record<string, any> = {
...row,
[detailFkColumn]: generatedKey,
company_code: companyCode,
writer: userId,
};
// 빈 값 필터링 및 id 제외
const detailCols = Object.keys(detailRowData).filter(k =>
k !== "id" &&
detailRowData[k] !== undefined &&
detailRowData[k] !== null &&
detailRowData[k] !== ""
);
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
const detailValues = detailCols.map(k => detailRowData[k]);
// RETURNING *로 삽입된 데이터 반환받기
const insertResult = await client.query(
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${detailPlaceholders.join(", ")}, NOW())
RETURNING *`,
detailValues
);
if (insertResult.rows && insertResult.rows[0]) {
insertedDetailRows.push(insertResult.rows[0]);
}
result.detailInserted++;
} catch (error: any) {
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
logger.error(`디테일 행 처리 실패:`, error);
}
}
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
await client.query("COMMIT");
result.success = result.errors.length === 0 || result.detailInserted > 0;
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
masterInserted: result.masterInserted,
detailInserted: result.detailInserted,
generatedKey: result.generatedKey,
errors: result.errors.length,
});
// 업로드 후 제어 실행 (단일 또는 다중)
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
? afterUploadFlows // 다중 제어
: afterUploadFlowId
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
: [];
if (flowsToExecute.length > 0 && result.success) {
try {
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
// 마스터 데이터 구성
const masterData = {
...masterFieldValues,
[relation!.masterKeyColumn]: result.generatedKey,
company_code: companyCode,
};
const controlResults: any[] = [];
// 순서대로 제어 실행
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}`);
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
const controlResult = await NodeFlowExecutionService.executeFlow(
parseInt(flow.flowId),
{
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
buttonId: "excel-upload-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: masterData,
// 추가 컨텍스트: 마스터/디테일 정보
masterData: masterData,
detailData: insertedDetailRows,
masterTable: relation!.masterTable,
detailTable: relation!.detailTable,
masterKeyColumn: relation!.masterKeyColumn,
detailFkColumn: relation!.detailFkColumn,
}
);
controlResults.push({
flowId: flow.flowId,
order: flow.order,
success: controlResult.success,
message: controlResult.message,
executedNodes: controlResult.nodes?.length || 0,
});
}
result.controlResult = {
success: controlResults.every(r => r.success),
executedFlows: controlResults.length,
results: controlResults,
};
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
} catch (controlError: any) {
logger.error(`업로드 후 제어 실행 실패:`, controlError);
result.controlResult = {
success: false,
message: `제어 실행 실패: ${controlError.message}`,
};
}
}
} catch (error: any) {
await client.query("ROLLBACK");
result.errors.push(`트랜잭션 실패: ${error.message}`);
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
} finally {
client.release();
}
return result;
}
/**
* ( numberingRuleService )
*/
private async generateNumberWithRule(
client: any,
ruleId: string,
companyCode: string
): Promise<string> {
try {
// 기존 numberingRuleService를 사용하여 코드 할당
const { numberingRuleService } = await import("./numberingRuleService");
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
return generatedCode;
} catch (error: any) {
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
throw error;
}
}
}
export const masterDetailExcelService = new MasterDetailExcelService();

View File

@ -2090,7 +2090,7 @@ export class MenuCopyService {
menu.menu_url, menu.menu_url,
menu.menu_desc, menu.menu_desc,
userId, userId,
'active', // 복제된 메뉴는 항상 활성화 상태 menu.status,
menu.system_name, menu.system_name,
targetCompanyCode, // 새 회사 코드 targetCompanyCode, // 새 회사 코드
menu.lang_key, menu.lang_key,

View File

@ -1,969 +0,0 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
/**
* -
*
* :
* 1. screen_groups menu_info: 화면관리
* 2. menu_info screen_groups: 사용자
*/
// ============================================================
// 타입 정의
// ============================================================
interface SyncResult {
success: boolean;
created: number;
linked: number;
skipped: number;
errors: string[];
details: SyncDetail[];
}
interface SyncDetail {
action: 'created' | 'linked' | 'skipped' | 'error';
sourceName: string;
sourceId: number | string;
targetId?: number | string;
reason?: string;
}
// ============================================================
// 화면관리 → 메뉴 동기화
// ============================================================
/**
* screen_groups를 menu_info로
*
* :
* 1. screen_groups ( )
* 2. menu_objid가
* 3. menu_info
* - 매칭되면: 양쪽에 ID
* - 안되면: menu_info에
* 4. (parent)
*/
export async function syncScreenGroupsToMenu(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
const screenGroupsQuery = `
SELECT
sg.id,
sg.group_name,
sg.group_code,
sg.parent_group_id,
sg.group_level,
sg.display_order,
sg.description,
sg.icon,
sg.menu_objid,
-- menu_objid도 ( )
parent.menu_objid as parent_menu_objid
FROM screen_groups sg
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
WHERE sg.company_code = $1
ORDER BY sg.group_level ASC, sg.display_order ASC
`;
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
// 경로 기반 매칭을 위해 부모 이름도 조회
const existingMenusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.parent_obj_id,
m.screen_group_id,
p.menu_name_kor as parent_name
FROM menu_info m
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
WHERE m.company_code = $1 AND m.menu_type = 1
`;
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const menuByPath: Map<string, any> = new Map();
const menuByName: Map<string, any> = new Map();
existingMenusResult.rows.forEach((menu: any) => {
if (!menu.screen_group_id) {
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
const parentName = menu.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
menuByPath.set(pathKey, menu);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!menuByName.has(menuName)) {
menuByName.set(menuName, menu);
}
}
});
// 모든 메뉴의 objid 집합 (삭제 확인용)
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
// 없으면 생성
let userMenuRootObjid: number | null = null;
const rootMenuQuery = `
SELECT objid FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
ORDER BY seq ASC
LIMIT 1
`;
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
if (rootMenuResult.rows.length > 0) {
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
} else {
// 루트 메뉴가 없으면 생성
const newObjid = Date.now();
const createRootQuery = `
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
RETURNING objid
`;
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
userMenuRootObjid = Number(createRootResult.rows[0].objid);
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
}
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
const groupToMenuMap: Map<number, number> = new Map();
// screen_groups의 부모 이름 조회를 위한 매핑
const groupIdToName: Map<number, string> = new Map();
screenGroupsResult.rows.forEach((g: any) => {
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
});
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
const topLevelCompanyFolderIds = new Set<number>();
for (const group of screenGroupsResult.rows) {
if (group.group_level === 0 && group.parent_group_id === null) {
topLevelCompanyFolderIds.add(group.id);
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
groupToMenuMap.set(group.id, userMenuRootObjid!);
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
}
}
// 6. 각 screen_group 처리
for (const group of screenGroupsResult.rows) {
const groupId = group.id;
const groupName = group.group_name?.trim();
const groupNameLower = groupName?.toLowerCase() || '';
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
if (topLevelCompanyFolderIds.has(groupId)) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
});
continue;
}
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
if (group.menu_objid) {
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
if (menuExists) {
// 메뉴가 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: groupName,
sourceId: groupId,
targetId: group.menu_objid,
reason: '이미 메뉴와 연결됨',
});
groupToMenuMap.set(groupId, Number(group.menu_objid));
continue;
} else {
// 메뉴가 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
await client.query(
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
[groupId]
);
// 계속 진행하여 재생성 또는 재연결
}
}
// 부모 그룹 이름 조회 (경로 기반 매칭용)
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedMenu = menuByPath.get(pathKey);
if (!matchedMenu) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedMenu = menuByName.get(groupNameLower);
}
if (matchedMenu) {
// 매칭된 메뉴와 연결
const menuObjid = Number(matchedMenu.objid);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
groupToMenuMap.set(groupId, menuObjid);
result.linked++;
result.details.push({
action: 'linked',
sourceName: groupName,
sourceId: groupId,
targetId: menuObjid,
});
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
menuByPath.delete(pathKey);
menuByName.delete(groupNameLower);
} else {
// 새 메뉴 생성
const newObjid = Date.now() + groupId; // 고유 ID 보장
// 부모 메뉴 objid 결정
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
let parentMenuObjid = userMenuRootObjid;
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
} else if (group.parent_group_id && group.parent_menu_objid) {
// 기존 parent_menu_objid가 실제로 존재하는지 확인
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
if (parentMenuExists) {
parentMenuObjid = Number(group.parent_menu_objid);
}
}
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
let nextSeq = 1;
const maxSeqQuery = `
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
FROM menu_info
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
`;
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
if (maxSeqResult.rows.length > 0) {
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
}
// menu_info에 삽입
const insertMenuQuery = `
INSERT INTO menu_info (
objid, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
RETURNING objid
`;
await client.query(insertMenuQuery, [
newObjid,
parentMenuObjid,
groupName,
group.group_code || groupName,
nextSeq,
companyCode,
userId,
groupId,
group.description || null,
]);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[newObjid, groupId]
);
groupToMenuMap.set(groupId, newObjid);
result.created++;
result.details.push({
action: 'created',
sourceName: groupName,
sourceId: groupId,
targetId: newObjid,
});
}
}
await client.query('COMMIT');
logger.info("화면관리 → 메뉴 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 메뉴 → 화면관리 동기화
// ============================================================
/**
* menu_info를 screen_groups로
*
* :
* 1. (menu_type=1)
* 2. screen_group_id가
* 3. screen_groups
* - 매칭되면: 양쪽에 ID
* - 안되면: screen_groups에 ()
* 4. (parent)
*/
export async function syncMenuToScreenGroups(
companyCode: string,
userId: string
): Promise<SyncResult> {
const result: SyncResult = {
success: true,
created: 0,
linked: 0,
skipped: 0,
errors: [],
details: [],
};
const client = await pool.connect();
try {
await client.query('BEGIN');
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
const menusQuery = `
SELECT
m.objid,
m.menu_name_kor,
m.menu_name_eng,
m.parent_obj_id,
m.seq,
m.menu_url,
m.menu_desc,
m.screen_group_id,
-- screen_group_id도 ( )
parent.screen_group_id as parent_screen_group_id
FROM menu_info m
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
WHERE m.company_code = $1 AND m.menu_type = 1
ORDER BY
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
m.parent_obj_id,
m.seq
`;
const menusResult = await client.query(menusQuery, [companyCode]);
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
const existingGroupsQuery = `
SELECT
g.id,
g.group_name,
g.menu_objid,
g.parent_group_id,
p.group_name as parent_name
FROM screen_groups g
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
WHERE g.company_code = $1
`;
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
// 단순 이름 매칭도 유지 (하위 호환)
const groupByPath: Map<string, any> = new Map();
const groupByName: Map<string, any> = new Map();
existingGroupsResult.rows.forEach((group: any) => {
if (!group.menu_objid) {
const groupName = group.group_name?.trim().toLowerCase() || '';
const parentName = group.parent_name?.trim().toLowerCase() || '';
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
groupByPath.set(pathKey, group);
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
if (!groupByName.has(groupName)) {
groupByName.set(groupName, group);
}
}
});
// 모든 그룹의 id 집합 (삭제 확인용)
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
let companyFolderId: number | null = null;
const companyFolderQuery = `
SELECT id FROM screen_groups
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
ORDER BY id ASC
LIMIT 1
`;
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
if (companyFolderResult.rows.length > 0) {
companyFolderId = companyFolderResult.rows[0].id;
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
} else {
// 회사 폴더가 없으면 생성
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
let nextRootOrder = 1;
const maxRootOrderQuery = `
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
FROM screen_groups
WHERE parent_group_id IS NULL
`;
const maxRootOrderResult = await client.query(maxRootOrderQuery);
if (maxRootOrderResult.rows.length > 0) {
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
}
const createFolderQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, hierarchy_path
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
RETURNING id
`;
const createFolderResult = await client.query(createFolderQuery, [
companyName,
companyCode.toLowerCase(),
nextRootOrder,
companyCode,
userId,
]);
companyFolderId = createFolderResult.rows[0].id;
// hierarchy_path 업데이트
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[`/${companyFolderId}/`, companyFolderId]
);
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
}
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
const menuToGroupMap: Map<number, number> = new Map();
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
menusResult.rows.forEach((menu: any) => {
if (menu.screen_group_id) {
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
}
});
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
let rootMenuObjid: number | null = null;
for (const menu of menusResult.rows) {
if (Number(menu.parent_obj_id) === 0) {
rootMenuObjid = Number(menu.objid);
// 루트 메뉴는 회사 폴더와 연결
if (companyFolderId) {
menuToGroupMap.set(rootMenuObjid, companyFolderId);
}
break;
}
}
// 5. 각 메뉴 처리
for (const menu of menusResult.rows) {
const menuObjid = Number(menu.objid);
const menuName = menu.menu_name_kor?.trim();
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
if (Number(menu.parent_obj_id) === 0) {
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: companyFolderId || undefined,
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
});
continue;
}
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
if (menu.screen_group_id) {
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
if (groupExists) {
// 그룹이 존재하면 스킵
result.skipped++;
result.details.push({
action: 'skipped',
sourceName: menuName,
sourceId: menuObjid,
targetId: menu.screen_group_id,
reason: '이미 화면그룹과 연결됨',
});
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
continue;
} else {
// 그룹이 삭제되었으면 연결 해제하고 재생성
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
await client.query(
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
[menuObjid]
);
// 계속 진행하여 재생성 또는 재연결
}
}
const menuNameLower = menuName?.toLowerCase() || '';
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
let matchedGroup = groupByPath.get(pathKey);
if (!matchedGroup) {
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
matchedGroup = groupByName.get(menuNameLower);
}
if (matchedGroup) {
// 매칭된 그룹과 연결
const groupId = Number(matchedGroup.id);
try {
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[groupId, menuObjid]
);
// screen_groups에 menu_objid 업데이트
await client.query(
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
[menuObjid, groupId]
);
menuToGroupMap.set(menuObjid, groupId);
result.linked++;
result.details.push({
action: 'linked',
sourceName: menuName,
sourceId: menuObjid,
targetId: groupId,
});
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
groupByPath.delete(pathKey);
groupByName.delete(menuNameLower);
} catch (linkError: any) {
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
throw linkError;
}
} else {
// 새 screen_group 생성
// 부모 그룹 ID 결정
let parentGroupId: number | null = null;
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
}
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
parentGroupId = companyFolderId;
}
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
parentGroupId = Number(menu.parent_screen_group_id);
}
// 부모 그룹의 레벨 조회
if (parentGroupId) {
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
if (parentLevelResult.rows.length > 0) {
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
}
}
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
let nextDisplayOrder = 1;
const maxOrderQuery = parentGroupId
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
if (maxOrderResult.rows.length > 0) {
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
}
// group_code 생성 (영문명 또는 이름 기반)
const groupCode = (menu.menu_name_eng || menuName || 'group')
.replace(/\s+/g, '_')
.toLowerCase()
.substring(0, 50);
// screen_groups에 삽입
const insertGroupQuery = `
INSERT INTO screen_groups (
group_name, group_code, parent_group_id, group_level,
display_order, company_code, writer, menu_objid, description
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`;
let newGroupId: number;
try {
logger.info("새 그룹 생성 시도", {
menuName,
menuObjid,
groupCode: groupCode + '_' + menuObjid,
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
});
const insertResult = await client.query(insertGroupQuery, [
menuName,
groupCode + '_' + menuObjid, // 고유성 보장
parentGroupId,
groupLevel,
nextDisplayOrder,
companyCode,
userId,
menuObjid,
menu.menu_desc || null,
]);
newGroupId = insertResult.rows[0].id;
} catch (insertError: any) {
logger.error("그룹 생성 중 에러", {
menuName,
menuObjid,
parentGroupId,
groupLevel,
error: insertError.message,
stack: insertError.stack,
code: insertError.code,
detail: insertError.detail,
});
throw insertError;
}
// hierarchy_path 업데이트
let hierarchyPath = `/${newGroupId}/`;
if (parentGroupId) {
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
}
}
await client.query(
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
[hierarchyPath, newGroupId]
);
// menu_info에 screen_group_id 업데이트
await client.query(
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
[newGroupId, menuObjid]
);
menuToGroupMap.set(menuObjid, newGroupId);
result.created++;
result.details.push({
action: 'created',
sourceName: menuName,
sourceId: menuObjid,
targetId: newGroupId,
});
}
}
await client.query('COMMIT');
logger.info("메뉴 → 화면관리 동기화 완료", {
companyCode,
created: result.created,
linked: result.linked,
skipped: result.skipped
});
return result;
} catch (error: any) {
await client.query('ROLLBACK');
logger.error("메뉴 → 화면관리 동기화 실패", {
companyCode,
error: error.message,
stack: error.stack,
code: error.code,
detail: error.detail,
});
result.success = false;
result.errors.push(error.message);
return result;
} finally {
client.release();
}
}
// ============================================================
// 동기화 상태 조회
// ============================================================
/**
*
*
* -
* -
* -
*/
export async function getSyncStatus(companyCode: string): Promise<{
screenGroups: { total: number; linked: number; unlinked: number };
menuItems: { total: number; linked: number; unlinked: number };
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
}> {
// screen_groups 상태
const sgQuery = `
SELECT
COUNT(*) as total,
COUNT(menu_objid) as linked
FROM screen_groups
WHERE company_code = $1
`;
const sgResult = await pool.query(sgQuery, [companyCode]);
// menu_info 상태 (사용자 메뉴만, 루트 제외)
const menuQuery = `
SELECT
COUNT(*) as total,
COUNT(screen_group_id) as linked
FROM menu_info
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
`;
const menuResult = await pool.query(menuQuery, [companyCode]);
// 이름이 같은 잠재적 매칭 후보 조회
const matchQuery = `
SELECT
m.menu_name_kor as menu_name,
sg.group_name
FROM menu_info m
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
WHERE m.company_code = $1
AND sg.company_code = $1
AND m.menu_type = 1
AND m.screen_group_id IS NULL
AND sg.menu_objid IS NULL
LIMIT 10
`;
const matchResult = await pool.query(matchQuery, [companyCode]);
const sgTotal = parseInt(sgResult.rows[0].total);
const sgLinked = parseInt(sgResult.rows[0].linked);
const menuTotal = parseInt(menuResult.rows[0].total);
const menuLinked = parseInt(menuResult.rows[0].linked);
return {
screenGroups: {
total: sgTotal,
linked: sgLinked,
unlinked: sgTotal - sgLinked,
},
menuItems: {
total: menuTotal,
linked: menuLinked,
unlinked: menuTotal - menuLinked,
},
potentialMatches: matchResult.rows.map((row: any) => ({
menuName: row.menu_name,
groupName: row.group_name,
similarity: 'exact',
})),
};
}
// ============================================================
// 전체 동기화 (모든 회사)
// ============================================================
interface AllCompaniesSyncResult {
success: boolean;
totalCompanies: number;
successCount: number;
failedCount: number;
results: Array<{
companyCode: string;
companyName: string;
direction: 'screens-to-menus' | 'menus-to-screens';
created: number;
linked: number;
skipped: number;
success: boolean;
error?: string;
}>;
}
/**
*
*
* :
* 1.
* 2.
* -
* -
* 3.
*/
export async function syncAllCompanies(
userId: string
): Promise<AllCompaniesSyncResult> {
const result: AllCompaniesSyncResult = {
success: true,
totalCompanies: 0,
successCount: 0,
failedCount: 0,
results: [],
};
try {
logger.info("전체 동기화 시작", { userId });
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
const companiesQuery = `
SELECT company_code, company_name
FROM company_mng
WHERE company_code != '*'
ORDER BY company_name
`;
const companiesResult = await pool.query(companiesQuery);
result.totalCompanies = companiesResult.rows.length;
// 각 회사별로 양방향 동기화
for (const company of companiesResult.rows) {
const companyCode = company.company_code;
const companyName = company.company_name;
try {
// 1. 화면관리 → 메뉴 동기화
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: screensToMenusResult.created,
linked: screensToMenusResult.linked,
skipped: screensToMenusResult.skipped,
success: screensToMenusResult.success,
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
});
// 2. 메뉴 → 화면관리 동기화
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
result.results.push({
companyCode,
companyName,
direction: 'menus-to-screens',
created: menusToScreensResult.created,
linked: menusToScreensResult.linked,
skipped: menusToScreensResult.skipped,
success: menusToScreensResult.success,
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
});
if (screensToMenusResult.success && menusToScreensResult.success) {
result.successCount++;
} else {
result.failedCount++;
}
} catch (error: any) {
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
result.results.push({
companyCode,
companyName,
direction: 'screens-to-menus',
created: 0,
linked: 0,
skipped: 0,
success: false,
error: error.message,
});
result.failedCount++;
}
}
logger.info("전체 동기화 완료", {
totalCompanies: result.totalCompanies,
successCount: result.successCount,
failedCount: result.failedCount,
});
return result;
} catch (error: any) {
logger.error("전체 동기화 실패", { error: error.message });
result.success = false;
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -969,56 +969,21 @@ export class NodeFlowExecutionService {
const insertedData = { ...data }; const insertedData = { ...data };
console.log("🗺️ 필드 매핑 처리 중..."); console.log("🗺️ 필드 매핑 처리 중...");
fieldMappings.forEach((mapping: any) => {
// 🔥 채번 규칙 서비스 동적 import
const { numberingRuleService } = await import("./numberingRuleService");
for (const mapping of fieldMappings) {
fields.push(mapping.targetField); fields.push(mapping.targetField);
let value: any; const value =
mapping.staticValue !== undefined
// 🔥 값 생성 유형에 따른 처리 ? mapping.staticValue
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source"); : data[mapping.sourceField];
if (valueType === "autoGenerate" && mapping.numberingRuleId) { console.log(
// 자동 생성 (채번 규칙) ` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
const companyCode = context.buttonContext?.companyCode || "*"; );
try {
value = await numberingRuleService.allocateCode(
mapping.numberingRuleId,
companyCode
);
console.log(
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
);
} catch (error: any) {
logger.error(`채번 규칙 적용 실패: ${error.message}`);
console.error(
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
);
throw new Error(
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
);
}
} else if (valueType === "static" || mapping.staticValue !== undefined) {
// 고정값
value = mapping.staticValue;
console.log(
` 📌 고정값: ${mapping.targetField} = ${value}`
);
} else {
// 소스 필드
value = data[mapping.sourceField];
console.log(
` ${mapping.sourceField}${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
);
}
values.push(value); values.push(value);
// 🔥 삽입된 값을 데이터에 반영 // 🔥 삽입된 값을 데이터에 반영
insertedData[mapping.targetField] = value; insertedData[mapping.targetField] = value;
} });
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
const hasWriterMapping = fieldMappings.some( const hasWriterMapping = fieldMappings.some(
@ -1563,24 +1528,16 @@ export class NodeFlowExecutionService {
} }
}); });
// 🔑 Primary Key 자동 추가 여부 결정: // 🔑 Primary Key 자동 추가 (context-data 모드)
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 console.log("🔑 context-data 모드: Primary Key 자동 추가");
// (사용자가 직접 조건을 설정한 경우 의도를 존중) const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
let finalWhereConditions: any[]; whereConditions,
if (whereConditions && whereConditions.length > 0) { data,
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); targetTable
finalWhereConditions = whereConditions; );
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause( const whereResult = this.buildWhereClause(
finalWhereConditions, enhancedWhereConditions,
data, data,
paramIndex paramIndex
); );
@ -1950,30 +1907,22 @@ export class NodeFlowExecutionService {
return deletedDataArray; return deletedDataArray;
} }
// 🆕 context-data 모드: 개별 삭제 // 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
console.log("🎯 context-data 모드: 개별 삭제 시작"); console.log("🎯 context-data 모드: 개별 삭제 시작");
for (const data of dataArray) { for (const data of dataArray) {
console.log("🔍 WHERE 조건 처리 중..."); console.log("🔍 WHERE 조건 처리 중...");
// 🔑 Primary Key 자동 추가 여부 결정: // 🔑 Primary Key 자동 추가 (context-data 모드)
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음 console.log("🔑 context-data 모드: Primary Key 자동 추가");
// (사용자가 직접 조건을 설정한 경우 의도를 존중) const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
let finalWhereConditions: any[]; whereConditions,
if (whereConditions && whereConditions.length > 0) { data,
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)"); targetTable
finalWhereConditions = whereConditions; );
} else {
console.log("🔑 context-data 모드: Primary Key 자동 추가");
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
whereConditions,
data,
targetTable
);
}
const whereResult = this.buildWhereClause( const whereResult = this.buildWhereClause(
finalWhereConditions, enhancedWhereConditions,
data, data,
1 1
); );
@ -2916,11 +2865,10 @@ export class NodeFlowExecutionService {
if (fieldValue === null || fieldValue === undefined || fieldValue === "") { if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
logger.info( logger.info(
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)` `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
); );
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환 // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨 return operator === "NOT_EXISTS_IN";
return false;
} }
try { try {
@ -4446,8 +4394,6 @@ export class NodeFlowExecutionService {
/** /**
* *
* : (leftOperand operator rightOperand) additionalOperations
* : (width * height) / 1000000 * qty
*/ */
private static evaluateArithmetic( private static evaluateArithmetic(
arithmetic: any, arithmetic: any,
@ -4474,67 +4420,27 @@ export class NodeFlowExecutionService {
const leftNum = Number(left) || 0; const leftNum = Number(left) || 0;
const rightNum = Number(right) || 0; const rightNum = Number(right) || 0;
// 기본 연산 수행 switch (arithmetic.operator) {
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
if (result === null) {
return null;
}
// 추가 연산 처리 (다중 연산 지원)
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
for (const addOp of arithmetic.additionalOperations) {
const operandValue = this.getOperandValue(
addOp.operand,
sourceRow,
targetRow,
resultValues
);
const operandNum = Number(operandValue) || 0;
result = this.applyOperator(result, addOp.operator, operandNum);
if (result === null) {
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
return null;
}
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
}
}
return result;
}
/**
*
*/
private static applyOperator(
left: number,
operator: string,
right: number
): number | null {
switch (operator) {
case "+": case "+":
return left + right; return leftNum + rightNum;
case "-": case "-":
return left - right; return leftNum - rightNum;
case "*": case "*":
return left * right; return leftNum * rightNum;
case "/": case "/":
if (right === 0) { if (rightNum === 0) {
logger.warn(`⚠️ 0으로 나누기 시도`); logger.warn(`⚠️ 0으로 나누기 시도`);
return null; return null;
} }
return left / right; return leftNum / rightNum;
case "%": case "%":
if (right === 0) { if (rightNum === 0) {
logger.warn(`⚠️ 0으로 나머지 연산 시도`); logger.warn(`⚠️ 0으로 나머지 연산 시도`);
return null; return null;
} }
return left % right; return leftNum % rightNum;
default: default:
throw new Error(`지원하지 않는 연산자: ${operator}`); throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
} }
} }

View File

@ -2597,10 +2597,10 @@ export class ScreenManagementService {
// 없으면 원본과 같은 회사에 복사 // 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만) // 3. 화면 코드 중복 체크 (대상 회사 기준)
const existingScreens = await client.query<any>( const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions `SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL WHERE screen_code = $1 AND company_code = $2
LIMIT 1`, LIMIT 1`,
[copyData.screenCode, targetCompanyCode] [copyData.screenCode, targetCompanyCode]
); );

View File

@ -187,68 +187,71 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
} }
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함) // 2. 카테고리 값 조회 (형제 메뉴 포함)
let query: string; let query: string;
let params: any[]; let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회 // 최고 관리자: 모든 카테고리 값 조회
if (menuObjid && siblingObjids.length > 0) { // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`; query = `
params = [tableName, columnName, siblingObjids]; SELECT
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids }); value_id AS "valueId",
} else if (menuObjid) { table_name AS "tableName",
query = baseSelect + ` AND menu_objid = $3`; column_name AS "columnName",
params = [tableName, columnName, menuObjid]; value_code AS "valueCode",
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid }); value_label AS "valueLabel",
} else { value_order AS "valueOrder",
// menuObjid 없으면 모든 값 조회 (중복 가능) parent_value_id AS "parentValueId",
query = baseSelect; depth,
params = [tableName, columnName]; description,
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)"); color,
} icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
} else { } else {
// 일반 회사: 자신의 회사 + menuObjid로 필터링 // 일반 회사: 자신의 카테고리 값만 조회
if (menuObjid && siblingObjids.length > 0) { // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`; query = `
params = [tableName, columnName, companyCode, siblingObjids]; SELECT
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids }); value_id AS "valueId",
} else if (menuObjid) { table_name AS "tableName",
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`; column_name AS "columnName",
params = [tableName, columnName, companyCode, menuObjid]; value_code AS "valueCode",
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid }); value_label AS "valueLabel",
} else { value_order AS "valueOrder",
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한) parent_value_id AS "parentValueId",
query = baseSelect + ` AND company_code = $3`; depth,
params = [tableName, columnName, companyCode]; description,
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode }); color,
} icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
} }
if (!includeInactive) { if (!includeInactive) {

View File

@ -1314,7 +1314,7 @@ export class TableManagementService {
// 각 값을 LIKE 또는 = 조건으로 처리 // 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = []; const conditions: string[] = [];
const values: any[] = []; const values: any[] = [];
value.forEach((v: any, idx: number) => { value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim(); const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
@ -1323,24 +1323,17 @@ export class TableManagementService {
// - "2," 로 시작 // - "2," 로 시작
// - ",2" 로 끝남 // - ",2" 로 끝남
// - ",2," 중간에 포함 // - ",2," 중간에 포함
const paramBase = paramIndex + idx * 4; const paramBase = paramIndex + (idx * 4);
conditions.push(`( conditions.push(`(
${columnName}::text = $${paramBase} OR ${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR ${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR ${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3} ${columnName}::text LIKE $${paramBase + 3}
)`); )`);
values.push( values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
safeValue,
`${safeValue},%`,
`%,${safeValue}`,
`%,${safeValue},%`
);
}); });
logger.info( logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
);
return { return {
whereClause: `(${conditions.join(" OR ")})`, whereClause: `(${conditions.join(" OR ")})`,
values, values,
@ -1779,29 +1772,21 @@ export class TableManagementService {
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색 // contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
const referenceColumn = entityTypeInfo.referenceColumn || "id"; const referenceColumn = entityTypeInfo.referenceColumn || "id";
const referenceTable = entityTypeInfo.referenceTable; const referenceTable = entityTypeInfo.referenceTable;
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn; let displayColumn = entityTypeInfo.displayColumn;
if ( if (!displayColumn || displayColumn === "none" || displayColumn === "") {
!displayColumn || displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
displayColumn === "none" ||
displayColumn === ""
) {
displayColumn = await this.findDisplayColumnForTable(
referenceTable,
referenceColumn
);
logger.info( logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
); );
} }
// 참조 테이블의 표시 컬럼으로 검색 // 참조 테이블의 표시 컬럼으로 검색
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
return { return {
whereClause: `EXISTS ( whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = main.${columnName} WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex} AND ref.${displayColumn} ILIKE $${paramIndex}
)`, )`,
values: [`%${value}%`], values: [`%${value}%`],
@ -2165,14 +2150,14 @@ export class TableManagementService {
// 안전한 테이블명 검증 // 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요) // 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`; const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
const countResult = await query<any>(countQuery, searchValues); const countResult = await query<any>(countQuery, searchValues);
const total = parseInt(countResult[0].count); const total = parseInt(countResult[0].count);
// 데이터 조회 (main 별칭 추가) // 데이터 조회
const dataQuery = ` const dataQuery = `
SELECT main.* FROM ${safeTableName} main SELECT * FROM ${safeTableName}
${whereClause} ${whereClause}
${orderClause} ${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@ -2509,7 +2494,7 @@ export class TableManagementService {
skippedColumns.push(column); skippedColumns.push(column);
return; return;
} }
const dataType = columnTypeMap.get(column) || "text"; const dataType = columnTypeMap.get(column) || "text";
setConditions.push( setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@ -2521,9 +2506,7 @@ export class TableManagementService {
}); });
if (skippedColumns.length > 0) { if (skippedColumns.length > 0) {
logger.info( logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
);
} }
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
@ -2728,12 +2711,6 @@ export class TableManagementService {
filterColumn?: string; filterColumn?: string;
filterValue?: any; filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
} }
): Promise<EntityJoinResponse> { ): Promise<EntityJoinResponse> {
const startTime = Date.now(); const startTime = Date.now();
@ -2784,74 +2761,33 @@ export class TableManagementService {
); );
for (const additionalColumn of options.additionalJoinColumns) { for (const additionalColumn of options.additionalJoinColumns) {
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기 // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
let baseJoinConfig = joinConfigs.find( const baseJoinConfig = joinConfigs.find(
(config) => config.sourceColumn === additionalColumn.sourceColumn (config) => config.sourceColumn === additionalColumn.sourceColumn
); );
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) =>
config.referenceTable ===
(additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`
);
}
}
if (baseJoinConfig) { if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출 // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) // sourceColumn을 제거한 나머지 부분이 실제 컬럼명
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
let actualColumnName: string;
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${frontendSourceColumn}_`,
""
);
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${sourceColumn}_`,
""
);
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
logger.info(`🔍 조인 컬럼 상세 분석:`, { logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn, sourceColumn,
frontendSourceColumn, joinAlias,
originalJoinAlias,
correctedJoinAlias,
actualColumnName, actualColumnName,
referenceTable: (additionalColumn as any).referenceTable, referenceTable: additionalColumn.sourceTable,
}); });
// 🚨 기본 Entity 조인과 중복되지 않도록 체크 // 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin = const isBasicEntityJoin =
correctedJoinAlias === `${sourceColumn}_name`; additionalColumn.joinAlias ===
`${baseJoinConfig.sourceColumn}_name`;
if (isBasicEntityJoin) { if (isBasicEntityJoin) {
logger.info( logger.info(
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀` `⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
); );
continue; // 기본 Entity 조인과 중복되면 추가하지 않음 continue; // 기본 Entity 조인과 중복되면 추가하지 않음
} }
@ -2859,14 +2795,14 @@ export class TableManagementService {
// 추가 조인 컬럼 설정 생성 // 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = { const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName, sourceTable: tableName,
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id) sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
referenceTable: referenceTable:
(additionalColumn as any).referenceTable || (additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng) baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code) referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name) displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
displayColumn: actualColumnName, // 하위 호환성 displayColumn: actualColumnName, // 하위 호환성
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name) aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
separator: " - ", // 기본 구분자 separator: " - ", // 기본 구분자
}; };
@ -3226,10 +3162,8 @@ export class TableManagementService {
} }
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함) // Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
const allEntityColumns = [ const allEntityColumns = [
...joinConfigs.map((config) => config.aliasColumn), ...joinConfigs.map((config) => config.aliasColumn),
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등) // 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
...joinConfigs.flatMap((config) => { ...joinConfigs.flatMap((config) => {
const additionalColumns = []; const additionalColumns = [];
@ -3635,10 +3569,8 @@ export class TableManagementService {
}); });
// main. 접두사 추가 (조인 쿼리용) // main. 접두사 추가 (조인 쿼리용)
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
condition = condition.replace( condition = condition.replace(
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"), new RegExp(`\\b${columnName}\\b`, "g"),
`main.${columnName}` `main.${columnName}`
); );
conditions.push(condition); conditions.push(condition);
@ -3840,12 +3772,9 @@ export class TableManagementService {
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요) // 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [ const companySpecificTables = [
"supplier_mng", "supplier_mng",
"customer_mng", "customer_mng",
"item_info", "item_info",
"dept_info", "dept_info",
"sales_order_mng", // 🔧 수주관리 테이블 추가
"sales_order_detail", // 🔧 수주상세 테이블 추가
"partner_info", // 🔧 거래처 테이블 추가
// 필요시 추가 // 필요시 추가
]; ];
@ -4756,7 +4685,7 @@ export class TableManagementService {
/** /**
* *
* column_labels에서 . * column_labels에서 .
* *
* @param leftTable * @param leftTable
* @param rightTable * @param rightTable
* @returns * @returns
@ -4764,20 +4693,16 @@ export class TableManagementService {
async detectTableEntityRelations( async detectTableEntityRelations(
leftTable: string, leftTable: string,
rightTable: string rightTable: string
): Promise< ): Promise<Array<{
Array<{ leftColumn: string;
leftColumn: string; rightColumn: string;
rightColumn: string; direction: "left_to_right" | "right_to_left";
direction: "left_to_right" | "right_to_left"; inputType: string;
inputType: string; displayColumn?: string;
displayColumn?: string; }>> {
}>
> {
try { try {
logger.info( logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
);
const relations: Array<{ const relations: Array<{
leftColumn: string; leftColumn: string;
rightColumn: string; rightColumn: string;
@ -4844,17 +4769,12 @@ export class TableManagementService {
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => { relations.forEach((rel, idx) => {
logger.info( logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
);
}); });
return relations; return relations;
} catch (error) { } catch (error) {
logger.error( logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
error
);
return []; return [];
} }
} }

View File

@ -17,30 +17,12 @@ export interface LangKey {
langKey: string; langKey: string;
description?: string; description?: string;
isActive: string; isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date; createdDate?: Date;
createdBy?: string; createdBy?: string;
updatedDate?: Date; updatedDate?: Date;
updatedBy?: string; updatedBy?: string;
} }
// 카테고리 인터페이스
export interface LangCategory {
categoryId: number;
categoryCode: string;
categoryName: string;
parentId?: number | null;
level: number;
keyPrefix: string;
description?: string;
sortOrder: number;
isActive: string;
children?: LangCategory[];
}
export interface LangText { export interface LangText {
textId?: number; textId?: number;
keyId: number; keyId: number;
@ -81,38 +63,10 @@ export interface CreateLangKeyRequest {
langKey: string; langKey: string;
description?: string; description?: string;
isActive?: string; isActive?: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdBy?: string; createdBy?: string;
updatedBy?: string; updatedBy?: string;
} }
// 자동 키 생성 요청
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
// 오버라이드 키 생성 요청
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
createdBy?: string;
}
export interface UpdateLangKeyRequest { export interface UpdateLangKeyRequest {
companyCode?: string; companyCode?: string;
menuName?: string; menuName?: string;
@ -136,8 +90,6 @@ export interface GetLangKeysParams {
menuCode?: string; menuCode?: string;
keyType?: string; keyType?: string;
searchText?: string; searchText?: string;
categoryId?: number;
includeOverrides?: boolean;
page?: number; page?: number;
limit?: number; limit?: number;
} }

View File

@ -588,4 +588,3 @@ const result = await executeNodeFlow(flowId, {

View File

@ -1,597 +0,0 @@
# 다국어 관리 시스템 개선 계획서
## 1. 개요
### 1.1 현재 시스템 분석
현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다.
| 항목 | 현재 상태 | 문제점 |
|------|----------|--------|
| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 |
| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 |
| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 |
| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 |
| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 |
### 1.2 개선 목표
1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원
2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정
3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류
4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성
5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인
---
## 2. 데이터베이스 스키마 설계
### 2.1 신규 테이블: multi_lang_category (카테고리 마스터)
```sql
CREATE TABLE multi_lang_category (
category_id SERIAL PRIMARY KEY,
category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등
category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등
parent_id INT4 REFERENCES multi_lang_category(category_id),
level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류
key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix
description TEXT,
sort_order INT4 DEFAULT 0,
is_active CHAR(1) DEFAULT 'Y',
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(50),
UNIQUE(category_code, COALESCE(parent_id, 0))
);
-- 인덱스
CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id);
CREATE INDEX idx_lang_category_level ON multi_lang_category(level);
```
### 2.2 기존 테이블 수정: multi_lang_key_master
```sql
-- 카테고리 연결 컬럼 추가
ALTER TABLE multi_lang_key_master
ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id);
-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값)
ALTER TABLE multi_lang_key_master
ADD COLUMN key_meaning VARCHAR(100);
-- 원본 키 참조 (오버라이드 시 원본 추적)
ALTER TABLE multi_lang_key_master
ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id);
-- menu_name을 usage_note로 변경 (사용 위치 메모)
ALTER TABLE multi_lang_key_master
RENAME COLUMN menu_name TO usage_note;
-- 인덱스 추가
CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id);
CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id);
CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id);
```
### 2.3 테이블 관계도
```
multi_lang_category (1) ◀────────┐
├── category_id (PK) │
├── category_code │
├── parent_id (자기참조) │
└── key_prefix │
multi_lang_key_master (N) ────────┘
├── key_id (PK)
├── company_code ('*' = 공통)
├── category_id (FK)
├── lang_key (자동 생성)
├── key_meaning (사용자 입력)
├── base_key_id (오버라이드 시 원본)
└── usage_note (사용 위치 메모)
multi_lang_text (N)
├── text_id (PK)
├── key_id (FK)
├── lang_code (FK → language_master)
└── lang_text
```
---
## 3. 카테고리 체계
### 3.1 대분류 (Level 1)
| category_code | category_name | key_prefix | 설명 |
|---------------|---------------|------------|------|
| COMMON | 공통 | common | 범용 텍스트 |
| BUTTON | 버튼 | button | 버튼 텍스트 |
| FORM | 폼 | form | 폼 라벨, 플레이스홀더 |
| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 |
| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 |
| MENU | 메뉴 | menu | 메뉴명, 네비게이션 |
| MODAL | 모달 | modal | 모달/다이얼로그 |
| VALIDATION | 검증 | validation | 유효성 검사 메시지 |
| STATUS | 상태 | status | 상태 표시 텍스트 |
| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 |
### 3.2 세부분류 (Level 2)
#### BUTTON 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| ACTION | 액션 | action |
| NAVIGATION | 네비게이션 | nav |
| TOGGLE | 토글 | toggle |
#### FORM 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| LABEL | 라벨 | label |
| PLACEHOLDER | 플레이스홀더 | placeholder |
| HELPER | 도움말 | helper |
#### MESSAGE 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| SUCCESS | 성공 | success |
| ERROR | 에러 | error |
| WARNING | 경고 | warning |
| INFO | 안내 | info |
| CONFIRM | 확인 | confirm |
#### TABLE 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| HEADER | 헤더 | header |
| EMPTY | 빈 상태 | empty |
| PAGINATION | 페이지네이션 | pagination |
#### MENU 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| ADMIN | 관리자 | admin |
| USER | 사용자 | user |
#### MODAL 하위
| category_code | category_name | key_prefix |
|---------------|---------------|------------|
| TITLE | 제목 | title |
| DESCRIPTION | 설명 | description |
### 3.3 키 자동 생성 규칙
**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}`
**예시**:
| 대분류 | 세부분류 | 의미 입력 | 생성 키 |
|--------|----------|----------|---------|
| BUTTON | ACTION | save | `button.action.save` |
| BUTTON | ACTION | delete_selected | `button.action.delete_selected` |
| FORM | LABEL | user_name | `form.label.user_name` |
| FORM | PLACEHOLDER | search | `form.placeholder.search` |
| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` |
| MESSAGE | ERROR | network_fail | `message.error.network_fail` |
| TABLE | HEADER | created_date | `table.header.created_date` |
| MENU | ADMIN | user_management | `menu.admin.user_management` |
---
## 4. 회사별 다국어 시스템
### 4.1 조회 우선순위
다국어 텍스트 조회 시 다음 우선순위를 적용합니다:
1. **회사 전용 키** (`company_code = 'COMPANY_A'`)
2. **공통 키** (`company_code = '*'`)
```sql
-- 조회 쿼리 예시
WITH ranked_keys AS (
SELECT
km.lang_key,
mt.lang_text,
km.company_code,
ROW_NUMBER() OVER (
PARTITION BY km.lang_key
ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END
) as priority
FROM multi_lang_key_master km
JOIN multi_lang_text mt ON km.key_id = mt.key_id
WHERE km.lang_key = ANY($2)
AND mt.lang_code = $3
AND km.is_active = 'Y'
AND km.company_code IN ($1, '*')
)
SELECT lang_key, lang_text
FROM ranked_keys
WHERE priority = 1;
```
### 4.2 오버라이드 프로세스
1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭
2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성
3. 기존 번역 텍스트 복사
4. 회사 관리자가 번역 수정
5. 이후 해당 회사 사용자는 회사 전용 번역 사용
### 4.3 권한 매트릭스
| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 |
|------|------------------|-------------|-------------|
| 공통 키 조회 | O | O | O |
| 공통 키 생성 | O | X | X |
| 공통 키 수정 | O | X | X |
| 공통 키 삭제 | O | X | X |
| 회사 키 조회 | O | 자사만 | 자사만 |
| 회사 키 생성 (오버라이드) | O | O | X |
| 회사 키 수정 | O | 자사만 | X |
| 회사 키 삭제 | O | 자사만 | X |
| 카테고리 관리 | O | X | X |
---
## 5. API 설계
### 5.1 카테고리 API
| 엔드포인트 | 메서드 | 설명 | 권한 |
|-----------|--------|------|------|
| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 |
| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 |
| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 |
| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 |
| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 |
### 5.2 다국어 키 API (개선)
| 엔드포인트 | 메서드 | 설명 | 권한 |
|-----------|--------|------|------|
| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 |
| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 |
| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 |
| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 |
| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 |
| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 |
| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 |
### 5.3 API 요청/응답 예시
#### 키 생성 요청
```json
POST /multilang/keys
{
"categoryId": 11, // 세부분류 ID (BUTTON > ACTION)
"keyMeaning": "save_changes",
"description": "변경사항 저장 버튼",
"usageNote": "사용자 관리, 설정 화면",
"texts": [
{ "langCode": "KR", "langText": "저장하기" },
{ "langCode": "US", "langText": "Save Changes" },
{ "langCode": "JP", "langText": "保存する" }
]
}
```
#### 키 생성 응답
```json
{
"success": true,
"message": "다국어 키가 생성되었습니다.",
"data": {
"keyId": 175,
"langKey": "button.action.save_changes",
"companyCode": "*",
"categoryId": 11
}
}
```
#### 오버라이드 요청
```json
POST /multilang/keys/123/override
{
"texts": [
{ "langCode": "KR", "langText": "등록하기" },
{ "langCode": "US", "langText": "Register" }
]
}
```
---
## 6. 프론트엔드 UI 설계
### 6.1 다국어 관리 페이지 리뉴얼
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 다국어 관리 │
│ 다국어 키와 번역 텍스트를 관리합니다 │
├─────────────────────────────────────────────────────────────────────────┤
│ [언어 관리] [다국어 키 관리] [카테고리 관리] │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤
│ │ 카테고리 필터 │ │ │
│ │ │ │ 검색: [________________] 회사: [전체 ▼] │
│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │
│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│
│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │
│ │ └ 토글 (5) │ │───────────────────────────────────────────────│
│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │
│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │
│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │
│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │
│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│
│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │
│ │ ▶ 메뉴 (9) │ │ │
│ └────────────────────┘ └───────────────────────────────────────────────┤
└─────────────────────────────────────────────────────────────────────────┘
```
### 6.2 키 등록 모달
```
┌─────────────────────────────────────────────────────────────────┐
│ 다국어 키 등록 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ① 카테고리 선택 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 대분류 * │ 세부 분류 *
│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │
│ │ │ ● 버튼 │ │ │ ● 액션 │ │
│ │ │ 폼 │ │ │ 네비게이션 │ │
│ │ │ 테이블 │ │ │ 토글 │ │
│ │ │ 메시지 │ │ │ │ │
│ │ └─────────────────────────┘ │ └─────────────────────────┘ │
│ └───────────────────────────────────────────────────────────────┤
│ │
│ ② 키 정보 입력 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 키 의미 (영문) * │
│ │ [ save_changes ] │
│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │
│ │ │
│ │ ───────────────────────────────────────────────────────── │
│ │ 자동 생성 키: │
│ │ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ button.action.save_changes │ │
│ │ └─────────────────────────────────────────────────────────┘ │
│ │ ✓ 사용 가능한 키입니다 │
│ └───────────────────────────────────────────────────────────────┤
│ │
│ ③ 설명 및 번역 │
│ ┌───────────────────────────────────────────────────────────────┤
│ │ 설명 (선택) │
│ │ [ 변경사항을 저장하는 버튼 ] │
│ │ │
│ │ 사용 위치 메모 (선택) │
│ │ [ 사용자 관리, 설정 화면 ] │
│ │ │
│ │ ───────────────────────────────────────────────────────── │
│ │ 번역 텍스트 │
│ │ │
│ │ 한국어 (KR) * [ 저장하기 ] │
│ │ English (US) [ Save Changes ] │
│ │ 日本語 (JP) [ 保存する ] │
│ └───────────────────────────────────────────────────────────────┤
│ │
├─────────────────────────────────────────────────────────────────┤
│ [취소] [등록] │
└─────────────────────────────────────────────────────────────────┘
```
### 6.3 공통 키 편집 모달 (회사 관리자용)
```
┌─────────────────────────────────────────────────────────────────┐
│ 다국어 키 상세 │
│ button.action.save (공통) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 카테고리: 버튼 > 액션 │
│ 설명: 저장 버튼 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ 번역 텍스트 (읽기 전용) │
│ │
│ 한국어 (KR) 저장 │
│ English (US) Save │
│ 日本語 (JP) 保存 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 공통 키는 수정할 수 없습니다. │
│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │
│ │
│ [이 회사 전용으로 복사] │
├─────────────────────────────────────────────────────────────────┤
│ [닫기] │
└─────────────────────────────────────────────────────────────────┘
```
### 6.4 회사 전용 키 생성 모달 (오버라이드)
```
┌─────────────────────────────────────────────────────────────────┐
│ 회사 전용 키 생성 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 원본 키: button.action.save (공통) │
│ │
│ 원본 번역: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 한국어: 저장 │ │
│ │ English: Save │ │
│ │ 日本語: 保存 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 이 회사 전용 번역 텍스트: │
│ │
│ 한국어 (KR) * [ 등록하기 ] │
│ English (US) [ Register ] │
│ 日本語 (JP) [ 登録 ] │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │
│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │
├─────────────────────────────────────────────────────────────────┤
│ [취소] [생성] │
└─────────────────────────────────────────────────────────────────┘
```
---
## 7. 구현 계획
### 7.1 Phase 1: 데이터베이스 마이그레이션
**예상 소요 시간: 2시간**
1. 카테고리 테이블 생성
2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개)
3. multi_lang_key_master 스키마 변경
4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭)
**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql`
### 7.2 Phase 2: 백엔드 API 개발
**예상 소요 시간: 4시간**
1. 카테고리 CRUD API
2. 키 조회 로직 수정 (우선순위 적용)
3. 권한 검사 미들웨어
4. 오버라이드 API
5. 키 중복 체크 API
6. 키 자동 생성 미리보기 API
**관련 파일**:
- `backend-node/src/controllers/multilangController.ts`
- `backend-node/src/services/multilangService.ts`
- `backend-node/src/routes/multilangRoutes.ts`
### 7.3 Phase 3: 프론트엔드 UI 개발
**예상 소요 시간: 6시간**
1. 카테고리 트리 컴포넌트
2. 키 등록 모달 리뉴얼 (단계별 입력)
3. 키 편집 모달 (권한별 UI 분기)
4. 오버라이드 모달
5. 카테고리 관리 탭 추가
**관련 파일**:
- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx`
- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼)
- `frontend/components/multilang/CategoryTree.tsx` (신규)
- `frontend/components/multilang/OverrideModal.tsx` (신규)
### 7.4 Phase 4: 테스트 및 마이그레이션
**예상 소요 시간: 2시간**
1. API 테스트
2. UI 테스트
3. 기존 데이터 마이그레이션 검증
4. 권한 테스트 (최고 관리자, 회사 관리자)
---
## 8. 상세 구현 일정
| 단계 | 작업 | 예상 시간 | 의존성 |
|------|------|----------|--------|
| 1.1 | 마이그레이션 SQL 작성 | 30분 | - |
| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 |
| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 |
| 1.4 | 스키마 변경 검증 | 30분 | 1.3 |
| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 |
| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 |
| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 |
| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 |
| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 |
| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 |
| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 |
| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 |
| 3.4 | 오버라이드 모달 | 1시간 | 3.3 |
| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 |
| 4.1 | 통합 테스트 | 1시간 | 3.5 |
| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 |
**총 예상 시간: 약 14시간**
---
## 9. 기대 효과
### 9.1 개선 전후 비교
| 항목 | 현재 | 개선 후 |
|------|------|---------|
| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) |
| 카테고리 분류 | 없음 | 2단계 계층 구조 |
| 회사별 다국어 | 미활용 | 오버라이드 지원 |
| 조회 우선순위 | 없음 | 회사 전용 > 공통 |
| 권한 관리 | 없음 | 역할별 접근 제어 |
| 중복 체크 | 저장 시에만 | 실시간 검증 |
| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 |
### 9.2 사용자 경험 개선
1. **일관된 키 명명**: 자동 생성으로 규칙 준수
2. **빠른 검색**: 카테고리 기반 필터링
3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용
4. **안전한 수정**: 권한 기반 보호
### 9.3 유지보수 개선
1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확
2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인
3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장
---
## 10. 참고 자료
### 10.1 관련 파일
| 파일 | 설명 |
|------|------|
| `frontend/hooks/useMultiLang.ts` | 다국어 훅 |
| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 |
| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 |
| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 |
| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 |
| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 |
### 10.2 데이터베이스 테이블
| 테이블 | 설명 |
|--------|------|
| `language_master` | 언어 마스터 (KR, US, JP) |
| `multi_lang_key_master` | 다국어 키 마스터 |
| `multi_lang_text` | 다국어 번역 텍스트 |
| `multi_lang_category` | 다국어 카테고리 (신규) |
---
## 11. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.0 | 2026-01-13 | AI | 최초 작성 |

View File

@ -361,4 +361,3 @@

View File

@ -347,4 +347,3 @@ const getComponentValue = (componentId: string) => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,127 +1,68 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList"; import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner"; import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager"; import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
// 단계별 진행을 위한 타입 정의 // 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template"; type Step = "list" | "design" | "template";
type ViewMode = "tree" | "table";
export default function ScreenManagementPage() { export default function ScreenManagementPage() {
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState<Step>("list"); const [currentStep, setCurrentStep] = useState<Step>("list");
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null); const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]); const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
try {
setLoading(true);
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
if (result.data && result.data.length > 0) {
setScreens(result.data);
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadScreens();
}, [loadScreens]);
// 화면 목록 새로고침 이벤트 리스너
useEffect(() => {
const handleScreenListRefresh = () => {
console.log("🔄 화면 목록 새로고침 이벤트 수신");
loadScreens();
};
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
return () => {
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
};
}, [loadScreens]);
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
useEffect(() => {
const openDesignerId = searchParams.get("openDesigner");
if (openDesignerId && screens.length > 0) {
const screenId = parseInt(openDesignerId, 10);
const targetScreen = screens.find((s) => s.screenId === screenId);
if (targetScreen) {
setSelectedScreen(targetScreen);
setCurrentStep("design");
setStepHistory(["list", "design"]);
}
}
}, [searchParams, screens]);
// 화면 설계 모드일 때는 전체 화면 사용 // 화면 설계 모드일 때는 전체 화면 사용
const isDesignMode = currentStep === "design"; const isDesignMode = currentStep === "design";
// 단계별 제목과 설명
const stepConfig = {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
},
};
// 다음 단계로 이동 // 다음 단계로 이동
const goToNextStep = (nextStep: Step) => { const goToNextStep = (nextStep: Step) => {
setStepHistory((prev) => [...prev, nextStep]); setStepHistory((prev) => [...prev, nextStep]);
setCurrentStep(nextStep); setCurrentStep(nextStep);
}; };
// 이전 단계로 이동
const goToPreviousStep = () => {
if (stepHistory.length > 1) {
const newHistory = stepHistory.slice(0, -1);
const previousStep = newHistory[newHistory.length - 1];
setStepHistory(newHistory);
setCurrentStep(previousStep);
}
};
// 특정 단계로 이동 // 특정 단계로 이동
const goToStep = (step: Step) => { const goToStep = (step: Step) => {
setCurrentStep(step); setCurrentStep(step);
// 해당 단계까지의 히스토리만 유지
const stepIndex = stepHistory.findIndex((s) => s === step); const stepIndex = stepHistory.findIndex((s) => s === step);
if (stepIndex !== -1) { if (stepIndex !== -1) {
setStepHistory(stepHistory.slice(0, stepIndex + 1)); setStepHistory(stepHistory.slice(0, stepIndex + 1));
} }
}; };
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제) // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setSelectedGroup(null); // 그룹 선택 해제
};
// 화면 디자인 핸들러
const handleDesignScreen = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
goToNextStep("design");
};
// 검색어로 필터링된 화면
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
// 단일 키워드면 해당 키워드로 화면 필터링
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
const filteredScreens = searchKeywords.length > 1
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
: screens.filter((screen) =>
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
);
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
if (isDesignMode) { if (isDesignMode) {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="fixed inset-0 z-50 bg-background">
@ -131,119 +72,59 @@ export default function ScreenManagementPage() {
} }
return ( return (
<div className="flex h-screen flex-col bg-background overflow-hidden"> <div className="flex min-h-screen flex-col bg-background">
{/* 페이지 헤더 */} <div className="space-y-6 p-6">
<div className="flex-shrink-0 border-b bg-background px-6 py-4"> {/* 페이지 헤더 */}
<div className="flex items-center justify-between"> <div className="space-y-2 border-b pb-4">
<div> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<h1 className="text-2xl font-bold tracking-tight"> </h1> <p className="text-sm text-muted-foreground"> 릿 </p>
<p className="text-sm text-muted-foreground"> </p> </div>
</div>
<div className="flex items-center gap-2"> {/* 단계별 내용 */}
{/* 뷰 모드 전환 */} <div className="flex-1">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}> {/* 화면 목록 단계 */}
<TabsList className="h-9"> {currentStep === "list" && (
<TabsTrigger value="tree" className="gap-1.5 px-3"> <ScreenList
<LayoutGrid className="h-4 w-4" /> onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
</TabsTrigger> onDesignScreen={(screen) => {
<TabsTrigger value="table" className="gap-1.5 px-3"> setSelectedScreen(screen);
<LayoutList className="h-4 w-4" /> goToNextStep("design");
}}
</TabsTrigger> />
</TabsList> )}
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}> {/* 템플릿 관리 단계 */}
<RefreshCw className="h-4 w-4" /> {currentStep === "template" && (
</Button> <div className="space-y-6">
<Button onClick={() => setIsCreateOpen(true)} className="gap-2"> <div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<Plus className="h-4 w-4" /> <h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
</Button> <Button
</div> variant="outline"
onClick={goToPreviousStep}
className="h-10 gap-2 text-sm font-medium"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Button
onClick={() => goToStep("list")}
className="h-10 gap-2 text-sm font-medium"
>
</Button>
</div>
</div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
)}
</div> </div>
</div> </div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null); // 화면 선택 해제
setFocusedScreenIdInGroup(null); // 포커스 초기화
}}
onScreenSelectInGroup={(group, screenId) => {
// 그룹 내 화면 클릭 시
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
initialFocusedScreenId={focusedScreenIdInGroup}
/>
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
</div>
)}
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
setIsCreateOpen(false);
loadScreens();
}}
/>
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>
); );
} }

View File

@ -7,19 +7,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus } from "lucide-react";
import { DataTable } from "@/components/common/DataTable"; import { DataTable } from "@/components/common/DataTable";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import LangKeyModal from "@/components/admin/LangKeyModal"; import LangKeyModal from "@/components/admin/LangKeyModal";
import LanguageModal from "@/components/admin/LanguageModal"; import LanguageModal from "@/components/admin/LanguageModal";
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { LangCategory } from "@/lib/api/multilang";
interface Language { interface Language {
langCode: string; langCode: string;
@ -35,7 +29,6 @@ interface LangKey {
langKey: string; langKey: string;
description: string; description: string;
isActive: string; isActive: string;
categoryId?: number;
} }
interface LangText { interface LangText {
@ -66,10 +59,6 @@ export default function I18nPage() {
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set()); const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys"); const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
// 카테고리 관련 상태
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]); const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
// 회사 목록 조회 // 회사 목록 조회
@ -103,14 +92,9 @@ export default function I18nPage() {
}; };
// 다국어 키 목록 조회 // 다국어 키 목록 조회
const fetchLangKeys = async (categoryId?: number | null) => { const fetchLangKeys = async () => {
try { try {
const params = new URLSearchParams(); const response = await apiClient.get("/multilang/keys");
if (categoryId) {
params.append("categoryId", categoryId.toString());
}
const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`;
const response = await apiClient.get(url);
const data = response.data; const data = response.data;
if (data.success) { if (data.success) {
setLangKeys(data.data); setLangKeys(data.data);
@ -487,13 +471,6 @@ export default function I18nPage() {
initializeData(); initializeData();
}, []); }, []);
// 카테고리 변경 시 키 목록 다시 조회
useEffect(() => {
if (!loading) {
fetchLangKeys(selectedCategory?.categoryId);
}
}, [selectedCategory?.categoryId]);
const columns = [ const columns = [
{ {
id: "select", id: "select",
@ -701,70 +678,27 @@ export default function I18nPage() {
{/* 다국어 키 관리 탭 */} {/* 다국어 키 관리 탭 */}
{activeTab === "keys" && ( {activeTab === "keys" && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
{/* 좌측: 카테고리 트리 (2/12) */} {/* 좌측: 언어 키 목록 (7/10) */}
<Card className="lg:col-span-2"> <Card className="lg:col-span-7">
<CardHeader className="py-3"> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm"></CardTitle> <CardTitle> </CardTitle>
</div>
</CardHeader>
<CardContent className="p-2">
<ScrollArea className="h-[500px]">
<CategoryTree
selectedCategoryId={selectedCategory?.categoryId || null}
onSelectCategory={(cat) => setSelectedCategory(cat)}
onDoubleClickCategory={(cat) => {
setSelectedCategory(cat);
setIsGenerateModalOpen(true);
}}
/>
</ScrollArea>
</CardContent>
</Card>
{/* 중앙: 언어 키 목록 (6/12) */}
<Card className="lg:col-span-6">
<CardHeader className="py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">
{selectedCategory && (
<Badge variant="secondary" className="ml-2">
{selectedCategory.categoryName}
</Badge>
)}
</CardTitle>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
size="sm"
variant="destructive"
onClick={handleDeleteSelectedKeys}
disabled={selectedKeys.size === 0}
>
({selectedKeys.size}) ({selectedKeys.size})
</Button> </Button>
<Button size="sm" variant="outline" onClick={handleAddKey}> <Button onClick={handleAddKey}> </Button>
</Button>
<Button
size="sm"
onClick={() => setIsGenerateModalOpen(true)}
disabled={!selectedCategory}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent>
{/* 검색 필터 영역 */} {/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3"> <div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div> <div>
<Label htmlFor="company" className="text-xs"></Label> <Label htmlFor="company"></Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}> <Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger>
<SelectValue placeholder="전체 회사" /> <SelectValue placeholder="전체 회사" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -779,22 +713,22 @@ export default function I18nPage() {
</div> </div>
<div> <div>
<Label htmlFor="search" className="text-xs"></Label> <Label htmlFor="search"></Label>
<Input <Input
placeholder="키명, 설명로 검색..." placeholder="키명, 설명, 메뉴, 회사로 검색..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="h-8 text-xs"
/> />
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<div className="text-xs text-muted-foreground">: {getFilteredLangKeys().length}</div> <div className="text-sm text-muted-foreground"> : {getFilteredLangKeys().length}</div>
</div> </div>
</div> </div>
{/* 테이블 영역 */} {/* 테이블 영역 */}
<div> <div>
<div className="mb-2 text-sm text-muted-foreground">: {getFilteredLangKeys().length}</div>
<DataTable <DataTable
columns={columns} columns={columns}
data={getFilteredLangKeys()} data={getFilteredLangKeys()}
@ -805,8 +739,8 @@ export default function I18nPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 우측: 선택된 키의 다국어 관리 (4/12) */} {/* 우측: 선택된 키의 다국어 관리 (3/10) */}
<Card className="lg:col-span-4"> <Card className="lg:col-span-3">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{selectedKey ? ( {selectedKey ? (
@ -883,18 +817,6 @@ export default function I18nPage() {
onSave={handleSaveLanguage} onSave={handleSaveLanguage}
languageData={editingLanguage} languageData={editingLanguage}
/> />
{/* 키 자동 생성 모달 */}
<KeyGenerateModal
isOpen={isGenerateModalOpen}
onClose={() => setIsGenerateModalOpen(false)}
selectedCategory={selectedCategory}
companyCode={user?.companyCode || ""}
isSuperAdmin={user?.companyCode === "*"}
onSuccess={() => {
fetchLangKeys(selectedCategory?.categoryId);
}}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -33,17 +33,8 @@ function ScreenViewPage() {
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
// 프리뷰 모드 감지 (iframe에서 로드될 때)
const isPreviewMode = searchParams.get("preview") === "true";
// 🆕 현재 로그인한 사용자 정보 // 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode: authCompanyCode } = useAuth(); const { user, userName, companyCode } = useAuth();
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
const companyCode = previewCompanyCode || authCompanyCode;
// 🆕 모바일 환경 감지 // 🆕 모바일 환경 감지
const { isMobile } = useResponsive(); const { isMobile } = useResponsive();
@ -113,7 +104,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록 // 편집 모달 이벤트 리스너 등록
useEffect(() => { useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => { const handleOpenEditModal = (event: CustomEvent) => {
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({ setEditModalConfig({
screenId: event.detail.screenId, screenId: event.detail.screenId,
@ -242,40 +233,27 @@ function ScreenViewPage() {
const designWidth = layout?.screenResolution?.width || 1200; const designWidth = layout?.screenResolution?.width || 1200;
const designHeight = layout?.screenResolution?.height || 800; const designHeight = layout?.screenResolution?.height || 800;
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용) // 컨테이너의 실제 크기
let containerWidth: number; const containerWidth = containerRef.current.offsetWidth;
let containerHeight: number; const containerHeight = containerRef.current.offsetHeight;
if (isPreviewMode) {
// iframe에서는 window 크기를 직접 사용
containerWidth = window.innerWidth;
containerHeight = window.innerHeight;
} else {
containerWidth = containerRef.current.offsetWidth;
containerHeight = containerRef.current.offsetHeight;
}
let newScale: number; // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8)
if (isPreviewMode) {
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
const scaleX = containerWidth / designWidth;
const scaleY = containerHeight / designHeight;
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
} else {
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
const MARGIN_X = 32; const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X; const availableWidth = containerWidth - MARGIN_X;
newScale = availableWidth / designWidth;
} // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
const newScale = availableWidth / designWidth;
// console.log("📐 스케일 계산:", { // console.log("📐 스케일 계산:", {
// containerWidth, // containerWidth,
// containerHeight, // containerHeight,
// MARGIN_X,
// availableWidth,
// designWidth, // designWidth,
// designHeight, // designHeight,
// finalScale: newScale, // finalScale: newScale,
// isPreviewMode, // "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
// }); // });
setScale(newScale); setScale(newScale);
@ -294,7 +272,7 @@ function ScreenViewPage() {
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
}; };
}, [layout, isMobile, isPreviewMode]); }, [layout, isMobile]);
if (loading) { if (loading) {
return ( return (
@ -332,7 +310,7 @@ function ScreenViewPage() {
<ScreenPreviewProvider isPreviewMode={false}> <ScreenPreviewProvider isPreviewMode={false}>
<ActiveTabProvider> <ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div ref={containerRef} className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}> <div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */} {/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && ( {!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br"> <div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">

View File

@ -388,18 +388,226 @@ select {
border-spacing: 0 !important; border-spacing: 0 !important;
} }
/* ===== 저장 테이블 막대기 애니메이션 ===== */ /* ===== POP (Production Operation Panel) Styles ===== */
@keyframes saveBarDrop {
0% { /* POP 전용 다크 테마 변수 */
transform: scaleY(0); .pop-dark {
transform-origin: top; /* 배경 색상 */
opacity: 0; --pop-bg-deepest: 8 12 21;
} --pop-bg-deep: 10 15 28;
--pop-bg-primary: 13 19 35;
--pop-bg-secondary: 18 26 47;
--pop-bg-tertiary: 25 35 60;
--pop-bg-elevated: 32 45 75;
/* 네온 강조색 */
--pop-neon-cyan: 0 212 255;
--pop-neon-cyan-bright: 0 240 255;
--pop-neon-cyan-dim: 0 150 190;
--pop-neon-pink: 255 0 102;
--pop-neon-purple: 138 43 226;
/* 상태 색상 */
--pop-success: 0 255 136;
--pop-success-dim: 0 180 100;
--pop-warning: 255 170 0;
--pop-warning-dim: 200 130 0;
--pop-danger: 255 51 51;
--pop-danger-dim: 200 40 40;
/* 텍스트 색상 */
--pop-text-primary: 255 255 255;
--pop-text-secondary: 180 195 220;
--pop-text-muted: 100 120 150;
/* 테두리 색상 */
--pop-border: 40 55 85;
--pop-border-light: 55 75 110;
}
/* POP 전용 라이트 테마 변수 */
.pop-light {
--pop-bg-deepest: 245 247 250;
--pop-bg-deep: 240 243 248;
--pop-bg-primary: 250 251 253;
--pop-bg-secondary: 255 255 255;
--pop-bg-tertiary: 245 247 250;
--pop-bg-elevated: 235 238 245;
--pop-neon-cyan: 0 122 204;
--pop-neon-cyan-bright: 0 140 230;
--pop-neon-cyan-dim: 0 100 170;
--pop-neon-pink: 220 38 127;
--pop-neon-purple: 118 38 200;
--pop-success: 22 163 74;
--pop-success-dim: 21 128 61;
--pop-warning: 245 158 11;
--pop-warning-dim: 217 119 6;
--pop-danger: 220 38 38;
--pop-danger-dim: 185 28 28;
--pop-text-primary: 15 23 42;
--pop-text-secondary: 71 85 105;
--pop-text-muted: 148 163 184;
--pop-border: 226 232 240;
--pop-border-light: 203 213 225;
}
/* POP 배경 그리드 패턴 */
.pop-bg-pattern::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
.pop-light .pop-bg-pattern::before {
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
}
/* POP 글로우 효과 */
.pop-glow-cyan {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
}
.pop-glow-cyan-strong {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
}
.pop-glow-success {
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
}
.pop-glow-warning {
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
}
.pop-glow-danger {
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
}
/* POP 펄스 글로우 애니메이션 */
@keyframes pop-pulse-glow {
0%,
100% { 100% {
transform: scaleY(1); box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
transform-origin: top; }
opacity: 1; 50% {
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
} }
} }
.pop-animate-pulse-glow {
animation: pop-pulse-glow 2s ease-in-out infinite;
}
/* POP 프로그레스 바 샤인 애니메이션 */
@keyframes pop-progress-shine {
0% {
opacity: 0;
transform: translateX(-20px);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(20px);
}
}
.pop-progress-shine::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
animation: pop-progress-shine 1.5s ease-in-out infinite;
}
/* POP 스크롤바 스타일 */
.pop-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.pop-scrollbar::-webkit-scrollbar-track {
background: rgb(var(--pop-bg-secondary));
}
.pop-scrollbar::-webkit-scrollbar-thumb {
background: rgb(var(--pop-border-light));
border-radius: 9999px;
}
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgb(var(--pop-neon-cyan-dim));
}
/* POP 스크롤바 숨기기 */
.pop-hide-scrollbar::-webkit-scrollbar {
display: none;
}
.pop-hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* ===== Marching Ants Animation (Excel Copy Border) ===== */
@keyframes marching-ants-h {
0% {
background-position: 0 0;
}
100% {
background-position: 16px 0;
}
}
@keyframes marching-ants-v {
0% {
background-position: 0 0;
}
100% {
background-position: 0 16px;
}
}
.animate-marching-ants-h {
background: repeating-linear-gradient(
90deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 16px 2px;
animation: marching-ants-h 0.4s linear infinite;
}
.animate-marching-ants-v {
background: repeating-linear-gradient(
180deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 2px 16px;
animation: marching-ants-v 0.4s linear infinite;
}
/* ===== End of Global Styles ===== */ /* ===== End of Global Styles ===== */

View File

@ -1,200 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
import { cn } from "@/lib/utils";
import { LangCategory, getCategories } from "@/lib/api/multilang";
interface CategoryTreeProps {
selectedCategoryId: number | null;
onSelectCategory: (category: LangCategory | null) => void;
onDoubleClickCategory?: (category: LangCategory) => void;
}
interface CategoryNodeProps {
category: LangCategory;
level: number;
selectedCategoryId: number | null;
onSelectCategory: (category: LangCategory) => void;
onDoubleClickCategory?: (category: LangCategory) => void;
}
function CategoryNode({
category,
level,
selectedCategoryId,
onSelectCategory,
onDoubleClickCategory,
}: CategoryNodeProps) {
// 기본값: 접힌 상태로 시작
const [isExpanded, setIsExpanded] = useState(false);
const hasChildren = category.children && category.children.length > 0;
const isSelected = selectedCategoryId === category.categoryId;
return (
<div>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelectCategory(category)}
onDoubleClick={() => onDoubleClickCategory?.(category)}
>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<span className="w-4" />
)}
{/* 폴더/태그 아이콘 */}
{hasChildren || level === 0 ? (
isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
)
) : (
<Tag className="h-4 w-4 shrink-0 text-blue-500" />
)}
{/* 카테고리 이름 */}
<span className="truncate">{category.categoryName}</span>
{/* prefix 표시 */}
<span
className={cn(
"ml-auto text-xs",
isSelected ? "text-primary-foreground/70" : "text-muted-foreground"
)}
>
{category.keyPrefix}
</span>
</div>
{/* 자식 카테고리 */}
{hasChildren && isExpanded && (
<div>
{category.children!.map((child) => (
<CategoryNode
key={child.categoryId}
category={child}
level={level + 1}
selectedCategoryId={selectedCategoryId}
onSelectCategory={onSelectCategory}
onDoubleClickCategory={onDoubleClickCategory}
/>
))}
</div>
)}
</div>
);
}
export function CategoryTree({
selectedCategoryId,
onSelectCategory,
onDoubleClickCategory,
}: CategoryTreeProps) {
const [categories, setCategories] = useState<LangCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
try {
setLoading(true);
const response = await getCategories();
if (response.success && response.data) {
setCategories(response.data);
} else {
setError(response.error?.details || "카테고리 로드 실패");
}
} catch (err) {
setError("카테고리 로드 중 오류 발생");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex h-32 items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
...
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-32 items-center justify-center">
<div className="text-sm text-destructive">{error}</div>
</div>
);
}
if (categories.length === 0) {
return (
<div className="flex h-32 items-center justify-center">
<div className="text-sm text-muted-foreground">
</div>
</div>
);
}
return (
<div className="space-y-0.5">
{/* 전체 선택 옵션 */}
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
selectedCategoryId === null
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
onClick={() => onSelectCategory(null)}
>
<Folder className="h-4 w-4 shrink-0" />
<span></span>
</div>
{/* 카테고리 트리 */}
{categories.map((category) => (
<CategoryNode
key={category.categoryId}
category={category}
level={0}
selectedCategoryId={selectedCategoryId}
onSelectCategory={onSelectCategory}
onDoubleClickCategory={onDoubleClickCategory}
/>
))}
</div>
);
}
export default CategoryTree;

View File

@ -1,497 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
LangCategory,
Language,
generateKey,
previewKey,
createOverrideKey,
getLanguages,
getCategoryPath,
KeyPreview,
} from "@/lib/api/multilang";
import { apiClient } from "@/lib/api/client";
interface Company {
companyCode: string;
companyName: string;
}
interface KeyGenerateModalProps {
isOpen: boolean;
onClose: () => void;
selectedCategory: LangCategory | null;
companyCode: string;
isSuperAdmin: boolean;
onSuccess: () => void;
}
export function KeyGenerateModal({
isOpen,
onClose,
selectedCategory,
companyCode,
isSuperAdmin,
onSuccess,
}: KeyGenerateModalProps) {
// 상태
const [keyMeaning, setKeyMeaning] = useState("");
const [usageNote, setUsageNote] = useState("");
const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode);
const [languages, setLanguages] = useState<Language[]>([]);
const [texts, setTexts] = useState<Record<string, string>>({});
const [categoryPath, setCategoryPath] = useState<LangCategory[]>([]);
const [preview, setPreview] = useState<KeyPreview | null>(null);
const [loading, setLoading] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [companySearchOpen, setCompanySearchOpen] = useState(false);
// 초기화
useEffect(() => {
if (isOpen) {
setKeyMeaning("");
setUsageNote("");
setTargetCompanyCode(isSuperAdmin ? "*" : companyCode);
setTexts({});
setPreview(null);
setError(null);
loadLanguages();
if (isSuperAdmin) {
loadCompanies();
}
if (selectedCategory) {
loadCategoryPath(selectedCategory.categoryId);
} else {
setCategoryPath([]);
}
}
}, [isOpen, selectedCategory, companyCode, isSuperAdmin]);
// 회사 목록 로드 (최고관리자 전용)
const loadCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
if (response.data.success && response.data.data) {
// snake_case를 camelCase로 변환하고 공통(*)은 제외
const companyList = response.data.data
.filter((c: any) => c.company_code !== "*")
.map((c: any) => ({
companyCode: c.company_code,
companyName: c.company_name,
}));
setCompanies(companyList);
}
} catch (err) {
console.error("회사 목록 로드 실패:", err);
}
};
// 언어 목록 로드
const loadLanguages = async () => {
const response = await getLanguages();
if (response.success && response.data) {
const activeLanguages = response.data.filter((l) => l.isActive === "Y");
setLanguages(activeLanguages);
// 초기 텍스트 상태 설정
const initialTexts: Record<string, string> = {};
activeLanguages.forEach((lang) => {
initialTexts[lang.langCode] = "";
});
setTexts(initialTexts);
}
};
// 카테고리 경로 로드
const loadCategoryPath = async (categoryId: number) => {
const response = await getCategoryPath(categoryId);
if (response.success && response.data) {
setCategoryPath(response.data);
}
};
// 키 미리보기 (디바운스)
const loadPreview = useCallback(async () => {
if (!selectedCategory || !keyMeaning.trim()) {
setPreview(null);
return;
}
setPreviewLoading(true);
try {
const response = await previewKey(
selectedCategory.categoryId,
keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
targetCompanyCode
);
if (response.success && response.data) {
setPreview(response.data);
}
} catch (err) {
console.error("키 미리보기 실패:", err);
} finally {
setPreviewLoading(false);
}
}, [selectedCategory, keyMeaning, targetCompanyCode]);
// keyMeaning 변경 시 디바운스로 미리보기 로드
useEffect(() => {
const timer = setTimeout(loadPreview, 500);
return () => clearTimeout(timer);
}, [loadPreview]);
// 텍스트 변경 핸들러
const handleTextChange = (langCode: string, value: string) => {
setTexts((prev) => ({ ...prev, [langCode]: value }));
};
// 저장 핸들러
const handleSave = async () => {
if (!selectedCategory) {
setError("카테고리를 선택해주세요");
return;
}
if (!keyMeaning.trim()) {
setError("키 의미를 입력해주세요");
return;
}
// 최소 하나의 텍스트 입력 검증
const hasText = Object.values(texts).some((t) => t.trim());
if (!hasText) {
setError("최소 하나의 언어에 대한 텍스트를 입력해주세요");
return;
}
setLoading(true);
setError(null);
try {
// 오버라이드 모드인지 확인
if (preview?.isOverride && preview.baseKeyId) {
// 오버라이드 키 생성
const response = await createOverrideKey({
companyCode: targetCompanyCode,
baseKeyId: preview.baseKeyId,
texts: Object.entries(texts)
.filter(([_, text]) => text.trim())
.map(([langCode, langText]) => ({ langCode, langText })),
});
if (response.success) {
onSuccess();
onClose();
} else {
setError(response.error?.details || "오버라이드 키 생성 실패");
}
} else {
// 새 키 생성
const response = await generateKey({
companyCode: targetCompanyCode,
categoryId: selectedCategory.categoryId,
keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
usageNote: usageNote.trim() || undefined,
texts: Object.entries(texts)
.filter(([_, text]) => text.trim())
.map(([langCode, langText]) => ({ langCode, langText })),
});
if (response.success) {
onSuccess();
onClose();
} else {
setError(response.error?.details || "키 생성 실패");
}
}
} catch (err: any) {
setError(err.message || "키 생성 중 오류 발생");
} finally {
setLoading(false);
}
};
// 생성될 키 미리보기
const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim()
? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".")
: "";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{preview?.isOverride
? "공통 키에 대한 회사별 오버라이드를 생성합니다"
: "새로운 다국어 키를 자동으로 생성합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 카테고리 경로 표시 */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<div className="mt-1 flex flex-wrap gap-1">
{categoryPath.length > 0 ? (
categoryPath.map((cat, idx) => (
<span key={cat.categoryId} className="flex items-center">
<Badge variant="secondary" className="text-xs">
{cat.categoryName}
</Badge>
{idx < categoryPath.length - 1 && (
<span className="mx-1 text-muted-foreground">/</span>
)}
</span>
))
) : (
<span className="text-sm text-muted-foreground">
</span>
)}
</div>
</div>
{/* 키 의미 입력 */}
<div>
<Label htmlFor="keyMeaning" className="text-xs sm:text-sm">
*
</Label>
<Input
id="keyMeaning"
value={keyMeaning}
onChange={(e) => setKeyMeaning(e.target.value)}
placeholder="예: add_new_item, search_button, save_success"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
(_)
</p>
</div>
{/* 생성될 키 미리보기 */}
{generatedKeyPreview && (
<div className={cn(
"rounded-md border p-3",
preview?.exists
? "border-destructive bg-destructive/10"
: preview?.isOverride
? "border-blue-500 bg-blue-500/10"
: "border-green-500 bg-green-500/10"
)}>
<div className="flex items-center gap-2">
{previewLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : preview?.exists ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : preview?.isOverride ? (
<Info className="h-4 w-4 text-blue-500" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
)}
<code className="text-xs font-mono sm:text-sm">
{generatedKeyPreview}
</code>
</div>
{preview?.exists && (
<p className="mt-1 text-xs text-destructive">
</p>
)}
{preview?.isOverride && !preview?.exists && (
<p className="mt-1 text-xs text-blue-600">
. .
</p>
)}
</div>
)}
{/* 대상 회사 선택 (최고 관리자만) */}
{isSuperAdmin && (
<div>
<Label className="text-xs sm:text-sm"></Label>
<div className="mt-1">
<Popover open={companySearchOpen} onOpenChange={setCompanySearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companySearchOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{targetCompanyCode === "*"
? "공통 (*) - 모든 회사 적용"
: companies.find((c) => c.companyCode === targetCompanyCode)
? `${companies.find((c) => c.companyCode === targetCompanyCode)?.companyName} (${targetCompanyCode})`
: "대상 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs sm:text-sm">
</CommandEmpty>
<CommandGroup>
<CommandItem
value="공통"
onSelect={() => {
setTargetCompanyCode("*");
setCompanySearchOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetCompanyCode === "*" ? "opacity-100" : "opacity-0"
)}
/>
(*) -
</CommandItem>
{companies.map((company) => (
<CommandItem
key={company.companyCode}
value={`${company.companyName} ${company.companyCode}`}
onSelect={() => {
setTargetCompanyCode(company.companyCode);
setCompanySearchOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetCompanyCode === company.companyCode ? "opacity-100" : "opacity-0"
)}
/>
{company.companyName} ({company.companyCode})
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)}
{/* 사용 메모 */}
<div>
<Label htmlFor="usageNote" className="text-xs sm:text-sm">
()
</Label>
<Textarea
id="usageNote"
value={usageNote}
onChange={(e) => setUsageNote(e.target.value)}
placeholder="이 키가 어디서 사용되는지 메모"
className="h-16 resize-none text-xs sm:text-sm"
/>
</div>
{/* 번역 텍스트 입력 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<div className="mt-2 space-y-2">
{languages.map((lang) => (
<div key={lang.langCode} className="flex items-center gap-2">
<Badge variant="outline" className="w-12 justify-center text-xs">
{lang.langCode}
</Badge>
<Input
value={texts[lang.langCode] || ""}
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
placeholder={`${lang.langName} 텍스트`}
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
/>
</div>
))}
</div>
</div>
{/* 에러 메시지 */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs sm:text-sm">
{error}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={loading || !selectedCategory || !keyMeaning.trim() || preview?.exists}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : preview?.isOverride ? (
"오버라이드 생성"
) : (
"키 생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default KeyGenerateModal;

View File

@ -34,35 +34,6 @@ import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet"; import { EditableSpreadsheet } from "./EditableSpreadsheet";
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
export interface MasterDetailExcelConfig {
// 테이블 정보
masterTable?: string;
detailTable?: string;
masterKeyColumn?: string;
detailFkColumn?: string;
// 채번
numberingRuleId?: string;
// 업로드 전 사용자가 선택할 마스터 테이블 필드
masterSelectFields?: Array<{
columnName: string;
columnLabel: string;
required: boolean;
inputType: "entity" | "date" | "text" | "select";
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
// 엑셀에서 매핑할 디테일 테이블 필드
detailExcelFields?: Array<{
columnName: string;
columnLabel: string;
required: boolean;
}>;
masterDefaults?: Record<string, any>;
detailDefaults?: Record<string, any>;
}
export interface ExcelUploadModalProps { export interface ExcelUploadModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@ -71,24 +42,6 @@ export interface ExcelUploadModalProps {
keyColumn?: string; keyColumn?: string;
onSuccess?: () => void; onSuccess?: () => void;
userId?: string; userId?: string;
// 마스터-디테일 지원
screenId?: number;
isMasterDetail?: boolean;
masterDetailRelation?: {
masterTable: string;
detailTable: string;
masterKeyColumn: string;
detailFkColumn: string;
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
};
// 🆕 마스터-디테일 엑셀 업로드 설정
masterDetailExcelConfig?: MasterDetailExcelConfig;
// 🆕 단일 테이블 채번 설정
numberingRuleId?: string;
numberingTargetColumn?: string;
// 🆕 업로드 후 제어 실행 설정
afterUploadFlows?: Array<{ flowId: string; order: number }>;
} }
interface ColumnMapping { interface ColumnMapping {
@ -104,15 +57,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
keyColumn, keyColumn,
onSuccess, onSuccess,
userId = "guest", userId = "guest",
screenId,
isMasterDetail = false,
masterDetailRelation,
masterDetailExcelConfig,
// 단일 테이블 채번 설정
numberingRuleId,
numberingTargetColumn,
// 업로드 후 제어 실행 설정
afterUploadFlows,
}) => { }) => {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
@ -135,116 +79,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
// 3단계: 확인 // 3단계: 확인
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
// 🆕 엔티티 참조 데이터 로드
useEffect(() => {
console.log("🔍 엔티티 데이터 로드 체크:", {
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
open,
isMasterDetail,
});
if (!masterDetailExcelConfig?.masterSelectFields) return;
const loadEntityData = async () => {
const { apiClient } = await import("@/lib/api/client");
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
for (const field of masterDetailExcelConfig.masterSelectFields!) {
console.log("🔍 필드 처리:", field);
if (field.inputType === "entity") {
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
try {
let refTable = field.referenceTable;
console.log("🔍 초기 refTable:", refTable);
let displayCol = field.displayColumn;
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
const colResponse = await apiClient.get(
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
);
console.log("🔍 컬럼 조회 응답:", colResponse.data);
if (colResponse.data?.success && colResponse.data?.data?.columns) {
const colInfo = colResponse.data.data.columns.find(
(c: any) => (c.columnName || c.column_name) === field.columnName
);
console.log("🔍 찾은 컬럼 정보:", colInfo);
if (colInfo) {
if (!refTable) {
refTable = colInfo.referenceTable || colInfo.reference_table;
console.log("🔍 DB에서 가져온 refTable:", refTable);
}
if (!displayCol) {
displayCol = colInfo.displayColumn || colInfo.display_column;
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
}
}
}
}
// displayColumn 저장 (Select 렌더링 시 사용)
if (displayCol) {
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
}
if (refTable) {
console.log("🔍 엔티티 데이터 조회:", refTable);
const response = await DynamicFormApi.getTableData(refTable, {
page: 1,
pageSize: 1000,
});
console.log("🔍 엔티티 데이터 응답:", response);
// getTableData는 { success, data: [...] } 형식으로 반환
const rows = response.data?.rows || response.data;
if (response.success && rows && Array.isArray(rows)) {
setEntitySearchData((prev) => ({
...prev,
[field.columnName]: rows,
}));
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
}
} else {
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
}
} catch (error) {
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
} finally {
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
}
}
}
};
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
loadEntityData();
}
}, [open, isMasterDetail, masterDetailExcelConfig]);
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
const hasMasterSelectFields = isSimpleMasterDetailMode &&
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
// 마스터 필드가 모두 입력되었는지 확인
const isMasterFieldsValid = () => {
if (!hasMasterSelectFields) return true;
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
if (!field.required) return true;
const value = masterFieldValues[field.columnName];
return value !== undefined && value !== null && value !== "";
});
};
// 파일 선택 핸들러 // 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]; const selectedFile = e.target.files?.[0];
@ -350,138 +184,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const loadTableSchema = async () => { const loadTableSchema = async () => {
try { try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode }); console.log("🔍 테이블 스키마 로드 시작:", { tableName });
let allColumns: TableColumn[] = []; const response = await getTableSchema(tableName);
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택) console.log("📊 테이블 스키마 응답:", response);
if (isSimpleMasterDetailMode && masterDetailRelation) {
const { detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택) if (response.success && response.data) {
const detailResponse = await getTableSchema(detailTable); // 자동 생성 컬럼 제외
if (detailResponse.success && detailResponse.data) { const filteredColumns = response.data.columns.filter(
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체 (col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
const configuredFields = masterDetailExcelConfig?.detailExcelFields; );
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
const detailCols = detailResponse.data.columns setSystemColumns(filteredColumns);
.filter((col) => {
// 자동 생성 컬럼, FK 컬럼 제외
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
if (col.name === detailFkColumn) return false;
// 설정된 필드가 있으면 해당 필드만
if (configuredFields && configuredFields.length > 0) {
return configuredFields.some((f) => f.columnName === col.name);
}
return true;
})
.map((col) => {
// 설정에서 라벨 찾기
const configField = configuredFields?.find((f) => f.columnName === col.name);
return {
...col,
label: configField?.columnLabel || col.label || col.name,
originalName: col.name,
sourceTable: detailTable,
};
});
allColumns = detailCols;
}
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length); // 기존 매핑 템플릿 조회
} console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기 const mappingResponse = await findMappingByColumns(tableName, excelColumns);
else if (isMasterDetail && masterDetailRelation) {
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
// 마스터 테이블 스키마 if (mappingResponse.success && mappingResponse.data) {
const masterResponse = await getTableSchema(masterTable); // 저장된 매핑 템플릿이 있으면 자동 적용
if (masterResponse.success && masterResponse.data) { console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const masterCols = masterResponse.data.columns const savedMappings = mappingResponse.data.columnMappings;
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${masterTable}.${col.name}`,
label: `[마스터] ${col.label || col.name}`,
originalName: col.name,
sourceTable: masterTable,
}));
allColumns = [...allColumns, ...masterCols];
}
// 디테일 테이블 스키마 (FK 컬럼 제외) const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
const detailResponse = await getTableSchema(detailTable); excelColumn: col,
if (detailResponse.success && detailResponse.data) { systemColumn: savedMappings[col] || null,
const detailCols = detailResponse.data.columns }));
.filter((col) => setColumnMappings(appliedMappings);
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && setIsAutoMappingLoaded(true);
col.name !== detailFkColumn // FK 컬럼 제외
)
.map((col) => ({
...col,
// 유니크 키를 위해 테이블명 접두사 추가
name: `${detailTable}.${col.name}`,
label: `[디테일] ${col.label || col.name}`,
originalName: col.name,
sourceTable: detailTable,
}));
allColumns = [...allColumns, ...detailCols];
}
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length); const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
} else { toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
// 기존 단일 테이블 모드
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
// 자동 생성 컬럼 제외
allColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
} else { } else {
console.error("❌ 테이블 스키마 로드 실패:", response); // 매핑 템플릿이 없으면 초기 상태로 설정
return; console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
} }
}
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
setSystemColumns(allColumns);
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else { } else {
// 매핑 템플릿이 없으면 초기 상태로 설정 console.error("❌ 테이블 스키마 로드 실패:", response);
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
} }
} catch (error) { } catch (error) {
console.error("❌ 테이블 스키마 로드 실패:", error); console.error("❌ 테이블 스키마 로드 실패:", error);
@ -493,35 +239,18 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const handleAutoMapping = () => { const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => { const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim(); const normalizedExcelCol = excelCol.toLowerCase().trim();
// [마스터], [디테일] 접두사 제거 후 비교
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후) // 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find((sysCol) => { let matchedSystemCol = systemColumns.find(
if (!sysCol.label) return false; (sysCol) =>
// [마스터], [디테일] 접두사 제거 후 비교 sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, ""); );
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
});
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) { if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find((sysCol) => { matchedSystemCol = systemColumns.find(
// 마스터-디테일 모드: originalName이 있으면 사용 (sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
const originalName = (sysCol as any).originalName; );
const colName = originalName || sysCol.name;
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
});
}
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find((sysCol) => {
// 테이블.컬럼 형식에서 컬럼만 추출
const nameParts = sysCol.name.split(".");
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
});
} }
return { return {
@ -556,12 +285,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return; return;
} }
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
toast.error("마스터 정보를 모두 입력해주세요.");
return;
}
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외 // 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) { if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링 // 빈 헤더가 아닌 열만 필터링
@ -621,12 +344,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const mappedRow: Record<string, any> = {}; const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => { columnMappings.forEach((mapping) => {
if (mapping.systemColumn) { if (mapping.systemColumn) {
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출 mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
let colName = mapping.systemColumn;
if (isMasterDetail && colName.includes(".")) {
colName = colName.split(".")[1];
}
mappedRow[colName] = row[mapping.excelColumn];
} }
}); });
return mappedRow; return mappedRow;
@ -646,133 +364,60 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}` `📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
); );
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번) let successCount = 0;
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) { let failCount = 0;
console.log("📊 마스터-디테일 간단 모드 업로드:", {
masterDetailRelation,
masterFieldValues,
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
});
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple( for (const row of filteredData) {
screenId, try {
filteredData, if (uploadMode === "insert") {
masterFieldValues, const formData = { screenId: 0, tableName, data: row };
masterDetailExcelConfig?.numberingRuleId || undefined, const result = await DynamicFormApi.saveFormData(formData);
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성 if (result.success) {
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어 successCount++;
); } else {
failCount++;
if (uploadResult.success && uploadResult.data) { }
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data; }
} catch (error) {
toast.success( failCount++;
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
} }
} }
// 🆕 마스터-디테일 기존 모드 처리
else if (isMasterDetail && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
const uploadResult = await DynamicFormApi.uploadMasterDetailData( if (successCount > 0) {
screenId, toast.success(
filteredData `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
); );
if (uploadResult.success && uploadResult.data) { // 매핑 템플릿 저장 (UPSERT - 자동 저장)
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data; try {
const mappingsToSave: Record<string, string | null> = {};
toast.success( columnMappings.forEach((mapping) => {
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` + mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "") });
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
); );
// 매핑 템플릿 저장 if (saveResult.success) {
await saveMappingTemplateInternal(); console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
onSuccess?.(); console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
} else { }
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다."); } catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
} }
onSuccess?.();
} else { } else {
// 기존 단일 테이블 업로드 로직 toast.error("업로드에 실패했습니다.");
let successCount = 0;
let failCount = 0;
// 단일 테이블 채번 설정 확인
const hasNumbering = numberingRuleId && numberingTargetColumn;
for (const row of filteredData) {
try {
let dataToSave = { ...row };
// 채번 적용: 각 행마다 채번 API 호출
if (hasNumbering && uploadMode === "insert") {
try {
const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingTargetColumn] = generatedCode;
}
} catch (numError) {
console.error("채번 오류:", numError);
}
}
if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: dataToSave };
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
} else {
failCount++;
}
}
} catch (error) {
failCount++;
}
}
// 🆕 업로드 후 제어 실행
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
try {
const { apiClient } = await import("@/lib/api/client");
// 순서대로 실행
const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
for (const flow of sortedFlows) {
await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
sourceData: { tableName, uploadedCount: successCount },
});
console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
}
} catch (controlError) {
console.error("제어 실행 오류:", controlError);
}
}
if (successCount > 0) {
toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
);
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
} else {
toast.error("업로드에 실패했습니다.");
}
} }
} catch (error) { } catch (error) {
console.error("❌ 엑셀 업로드 실패:", error); console.error("❌ 엑셀 업로드 실패:", error);
@ -782,35 +427,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
}; };
// 매핑 템플릿 저장 헬퍼 함수
const saveMappingTemplateInternal = async () => {
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
};
// 모달 닫기 시 초기화 // 모달 닫기 시 초기화
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -825,8 +441,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]); setExcelColumns([]);
setSystemColumns([]); setSystemColumns([]);
setColumnMappings([]); setColumnMappings([]);
// 🆕 마스터-디테일 모드 초기화
setMasterFieldValues({});
} }
}, [open]); }, [open]);
@ -847,21 +461,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg"> <DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" /> <FileSpreadsheet className="h-5 w-5" />
{isMasterDetail && (
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
-
</span>
)}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
{isMasterDetail && masterDetailRelation ? ( .
<>
({masterDetailRelation.masterTable}) + ({masterDetailRelation.detailTable}) .
.
</>
) : (
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -916,87 +518,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* 1단계: 파일 선택 & 미리보기 (통합) */} {/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-4"> <div className="space-y-4">
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
{hasMasterSelectFields && (
<div className="grid gap-3 sm:grid-cols-2">
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
<div key={field.columnName} className="space-y-1">
<Label className="text-xs">
{field.columnLabel}
{field.required && <span className="ml-1 text-destructive">*</span>}
</Label>
{field.inputType === "entity" ? (
<Select
value={masterFieldValues[field.columnName]?.toString() || ""}
onValueChange={(value) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: value,
}))
}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={`${field.columnLabel} 선택`} />
</SelectTrigger>
<SelectContent>
{entitySearchLoading[field.columnName] ? (
<SelectItem value="loading" disabled>
...
</SelectItem>
) : (
entitySearchData[field.columnName]?.map((item: any) => {
const keyValue = item[field.referenceColumn || "id"];
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
const displayColName =
field.displayColumn ||
entityDisplayColumns[field.columnName] ||
field.referenceColumn ||
"id";
const displayValue = item[displayColName] || keyValue;
return (
<SelectItem
key={keyValue}
value={keyValue?.toString()}
className="text-xs"
>
{displayValue}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
) : field.inputType === "date" ? (
<input
type="date"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
) : (
<input
type="text"
value={masterFieldValues[field.columnName] || ""}
onChange={(e) =>
setMasterFieldValues((prev) => ({
...prev,
[field.columnName]: e.target.value,
}))
}
placeholder={field.columnLabel}
className="h-9 w-full rounded-md border px-3 text-xs"
/>
)}
</div>
))}
</div>
)}
{/* 파일 선택 영역 */} {/* 파일 선택 영역 */}
<div> <div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm"> <Label htmlFor="file-upload" className="text-xs sm:text-sm">

View File

@ -175,21 +175,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (editData) { if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
// 🆕 배열인 경우 두 가지 데이터를 설정: // 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
if (Array.isArray(editData)) { if (Array.isArray(editData)) {
const firstRecord = editData[0] || {}; console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, { setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
formData: "첫 번째 레코드 (일반 입력 필드용)", setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
});
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
} else { } else {
setFormData(editData); setFormData(editData);
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
} else { } else {

View File

@ -65,10 +65,6 @@ const nodeTypes = {
*/ */
interface FlowEditorInnerProps { interface FlowEditorInnerProps {
initialFlowId?: number | null; initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 */
embedded?: boolean;
} }
// 플로우 에디터 툴바 버튼 설정 // 플로우 에디터 툴바 버튼 설정
@ -91,7 +87,7 @@ const flowToolbarButtons: ToolbarButton[] = [
}, },
]; ];
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) { function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition, setCenter } = useReactFlow(); const { screenToFlowPosition, setCenter } = useReactFlow();
@ -389,7 +385,7 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
{/* 상단 툴바 */} {/* 상단 툴바 */}
<Panel position="top-center" className="pointer-events-auto"> <Panel position="top-center" className="pointer-events-auto">
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} /> <FlowToolbar validations={validations} />
</Panel> </Panel>
</ReactFlow> </ReactFlow>
</div> </div>
@ -420,21 +416,13 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
*/ */
interface FlowEditorProps { interface FlowEditorProps {
initialFlowId?: number | null; initialFlowId?: number | null;
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
embedded?: boolean;
} }
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) { export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<ReactFlowProvider> <ReactFlowProvider>
<FlowEditorInner <FlowEditorInner initialFlowId={initialFlowId} />
initialFlowId={initialFlowId}
onSaveComplete={onSaveComplete}
embedded={embedded}
/>
</ReactFlowProvider> </ReactFlowProvider>
</div> </div>
); );

View File

@ -17,11 +17,9 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps { interface FlowToolbarProps {
validations?: FlowValidation[]; validations?: FlowValidation[];
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
} }
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) { export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const { toast } = useToast(); const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow(); const { zoomIn, zoomOut, fitView } = useReactFlow();
const { const {
@ -61,27 +59,13 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const result = await saveFlow(); const result = await saveFlow();
if (result.success) { if (result.success) {
toast({ toast({
title: "저장 완료", title: "✅ 플로우 저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`, description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default", variant: "default",
}); });
// 임베디드 모드에서 저장 완료 콜백 호출
if (onSaveComplete && result.flowId) {
onSaveComplete(result.flowId, flowName);
}
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
if (window.opener && result.flowId) {
window.opener.postMessage({
type: "FLOW_SAVED",
flowId: result.flowId,
flowName: flowName,
}, "*");
}
} else { } else {
toast({ toast({
title: "저장 실패", title: "❌ 저장 실패",
description: result.message, description: result.message,
variant: "destructive", variant: "destructive",
}); });

View File

@ -28,14 +28,6 @@ const OPERATOR_LABELS: Record<string, string> = {
"%": "%", "%": "%",
}; };
// 피연산자를 문자열로 변환
function getOperandStr(operand: any): string {
if (!operand) return "?";
if (operand.type === "static") return String(operand.value || "?");
if (operand.fieldLabel) return operand.fieldLabel;
return operand.field || operand.resultField || "?";
}
// 수식 요약 생성 // 수식 요약 생성
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string { function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation; const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
@ -43,19 +35,11 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
switch (formulaType) { switch (formulaType) {
case "arithmetic": { case "arithmetic": {
if (!arithmetic) return "미설정"; if (!arithmetic) return "미설정";
const leftStr = getOperandStr(arithmetic.leftOperand); const left = arithmetic.leftOperand;
const rightStr = getOperandStr(arithmetic.rightOperand); const right = arithmetic.rightOperand;
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`; const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
// 추가 연산 표시 return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
for (const addOp of arithmetic.additionalOperations) {
const opStr = getOperandStr(addOp.operand);
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
}
}
return formula;
} }
case "function": { case "function": {
if (!func) return "미설정"; if (!func) return "미설정";

View File

@ -797,85 +797,6 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
index, index,
)} )}
</div> </div>
{/* 추가 연산 목록 */}
{trans.arithmetic.additionalOperations && trans.arithmetic.additionalOperations.length > 0 && (
<div className="space-y-2 border-t pt-2">
<Label className="text-xs text-gray-500"> </Label>
{trans.arithmetic.additionalOperations.map((addOp: any, addIndex: number) => (
<div key={addIndex} className="flex items-center gap-2 rounded bg-orange-50 p-2">
<Select
value={addOp.operator}
onValueChange={(value) => {
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operator: value };
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ARITHMETIC_OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1">
{renderOperandSelector(
addOp.operand,
(updates) => {
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operand: updates };
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
},
index,
)}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const newAdditionalOps = trans.arithmetic!.additionalOperations!.filter(
(_: any, i: number) => i !== addIndex
);
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{/* 추가 연산 버튼 */}
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
onClick={() => {
const newAdditionalOps = [
...(trans.arithmetic!.additionalOperations || []),
{ operator: "*", operand: { type: "static" as const, value: "" } },
];
handleTransformationChange(index, {
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
});
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div> </div>
)} )}

View File

@ -5,7 +5,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react"; import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -18,8 +18,6 @@ import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import { getNumberingRules } from "@/lib/api/numberingRule";
import type { NumberingRuleConfig } from "@/types/numbering-rule";
import type { InsertActionNodeData } from "@/types/node-editor"; import type { InsertActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
@ -91,11 +89,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {}); const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || ""); const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 🔥 채번 규칙 관련 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
// 데이터 변경 시 로컬 상태 업데이트 // 데이터 변경 시 로컬 상태 업데이트
useEffect(() => { useEffect(() => {
setDisplayName(data.displayName || data.targetTable); setDisplayName(data.displayName || data.targetTable);
@ -135,33 +128,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
useEffect(() => { useEffect(() => {
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false)); setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
}, [fieldMappings.length]); }, [fieldMappings.length]);
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
useEffect(() => {
const loadNumberingRules = async () => {
setNumberingRulesLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data);
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
} else {
console.error("❌ 채번 규칙 로딩 실패:", response.error);
setNumberingRules([]);
}
} catch (error) {
console.error("❌ 채번 규칙 로딩 오류:", error);
setNumberingRules([]);
} finally {
setNumberingRulesLoading(false);
}
};
loadNumberingRules();
}, []);
// 🔥 외부 테이블 변경 시 컬럼 로드 // 🔥 외부 테이블 변경 시 컬럼 로드
useEffect(() => { useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) { if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
@ -572,7 +540,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
sourceField: null, sourceField: null,
targetField: "", targetField: "",
staticValue: undefined, staticValue: undefined,
valueType: "source" as const, // 🔥 기본값: 소스 필드
}, },
]; ];
setFieldMappings(newMappings); setFieldMappings(newMappings);
@ -581,7 +548,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// Combobox 열림 상태 배열 초기화 // Combobox 열림 상태 배열 초기화
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
}; };
const handleRemoveMapping = (index: number) => { const handleRemoveMapping = (index: number) => {
@ -592,7 +558,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
// Combobox 열림 상태 배열도 업데이트 // Combobox 열림 상태 배열도 업데이트
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false)); setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
}; };
const handleMappingChange = (index: number, field: string, value: any) => { const handleMappingChange = (index: number, field: string, value: any) => {
@ -621,24 +586,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
targetField: value, targetField: value,
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value, targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
}; };
} else if (field === "valueType") {
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
newMappings[index] = {
...newMappings[index],
valueType: value,
// 유형 변경 시 다른 유형의 값 초기화
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
...(value !== "static" && { staticValue: undefined }),
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
};
} else if (field === "numberingRuleId") {
// 🔥 채번 규칙 선택 시 이름도 함께 저장
const selectedRule = numberingRules.find((r) => r.ruleId === value);
newMappings[index] = {
...newMappings[index],
numberingRuleId: value,
numberingRuleName: selectedRule?.ruleName,
};
} else { } else {
newMappings[index] = { newMappings[index] = {
...newMappings[index], ...newMappings[index],
@ -1218,203 +1165,54 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{/* 🔥 값 생성 유형 선택 */} {/* 소스 필드 입력/선택 */}
<div> <div>
<Label className="text-xs text-gray-600"> </Label> <Label className="text-xs text-gray-600">
<div className="mt-1 grid grid-cols-3 gap-1">
<button {hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - )</span>}
type="button" </Label>
onClick={() => handleMappingChange(index, "valueType", "source")} {hasRestAPISource ? (
className={cn( // REST API 소스인 경우: 직접 입력
"rounded border px-2 py-1 text-xs transition-all",
(mapping.valueType === "source" || !mapping.valueType)
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-gray-200 hover:border-gray-300",
)}
>
</button>
<button
type="button"
onClick={() => handleMappingChange(index, "valueType", "static")}
className={cn(
"rounded border px-2 py-1 text-xs transition-all",
mapping.valueType === "static"
? "border-orange-500 bg-orange-50 text-orange-700"
: "border-gray-200 hover:border-gray-300",
)}
>
</button>
<button
type="button"
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
className={cn(
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
mapping.valueType === "autoGenerate"
? "border-purple-500 bg-purple-50 text-purple-700"
: "border-gray-200 hover:border-gray-300",
)}
>
<Sparkles className="h-3 w-3" />
</button>
</div>
</div>
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
{(mapping.valueType === "source" || !mapping.valueType) && (
<div>
<Label className="text-xs text-gray-600">
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - )</span>}
</Label>
{hasRestAPISource ? (
// REST API 소스인 경우: 직접 입력
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="필드명 입력 (예: userId, userName)"
className="mt-1 h-8 text-xs"
/>
) : (
// 일반 소스인 경우: Combobox 선택
<Popover
open={mappingSourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...mappingSourceFieldsOpenState];
newState[index] = open;
setMappingSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mappingSourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{mapping.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === mapping.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || mapping.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div>
);
})()
: "소스 필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleMappingChange(index, "sourceField", currentValue || null);
const newState = [...mappingSourceFieldsOpenState];
newState[index] = false;
setMappingSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{hasRestAPISource && (
<p className="mt-1 text-xs text-gray-500">API JSON의 </p>
)}
</div>
)}
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
{mapping.valueType === "static" && (
<div>
<Label className="text-xs text-gray-600"></Label>
<Input <Input
value={mapping.staticValue || ""} value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)} onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="고정값 입력" placeholder="필드명 입력 (예: userId, userName)"
className="mt-1 h-8 text-xs" className="mt-1 h-8 text-xs"
/> />
</div> ) : (
)} // 일반 소스인 경우: Combobox 선택
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
{mapping.valueType === "autoGenerate" && (
<div>
<Label className="text-xs text-gray-600">
{numberingRulesLoading && <span className="ml-1 text-gray-400">( ...)</span>}
</Label>
<Popover <Popover
open={mappingNumberingRulesOpenState[index]} open={mappingSourceFieldsOpenState[index]}
onOpenChange={(open) => { onOpenChange={(open) => {
const newState = [...mappingNumberingRulesOpenState]; const newState = [...mappingSourceFieldsOpenState];
newState[index] = open; newState[index] = open;
setMappingNumberingRulesOpenState(newState); setMappingSourceFieldsOpenState(newState);
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={mappingNumberingRulesOpenState[index]} aria-expanded={mappingSourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm" className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={numberingRulesLoading || numberingRules.length === 0}
> >
{mapping.numberingRuleId {mapping.sourceField
? (() => { ? (() => {
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId); const field = sourceFields.find((f) => f.name === mapping.sourceField);
return ( return (
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center justify-between gap-2 overflow-hidden">
<Sparkles className="h-3 w-3 text-purple-500" />
<span className="truncate font-medium"> <span className="truncate font-medium">
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId} {field?.label || mapping.sourceField}
</span> </span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div> </div>
); );
})() })()
: "채번 규칙 선택"} : "소스 필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -1424,36 +1222,37 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
align="start" align="start"
> >
<Command> <Command>
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" /> <CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
<CommandList> <CommandList>
<CommandEmpty className="text-xs sm:text-sm"> <CommandEmpty className="text-xs sm:text-sm">
. .
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{numberingRules.map((rule) => ( {sourceFields.map((field) => (
<CommandItem <CommandItem
key={rule.ruleId} key={field.name}
value={rule.ruleId} value={field.name}
onSelect={(currentValue) => { onSelect={(currentValue) => {
handleMappingChange(index, "numberingRuleId", currentValue); handleMappingChange(index, "sourceField", currentValue || null);
const newState = [...mappingNumberingRulesOpenState]; const newState = [...mappingSourceFieldsOpenState];
newState[index] = false; newState[index] = false;
setMappingNumberingRulesOpenState(newState); setMappingSourceFieldsOpenState(newState);
}} }}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0", mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{rule.ruleName}</span> <span className="font-medium">{field.label || field.name}</span>
<span className="text-muted-foreground font-mono text-[10px]"> {field.label && field.label !== field.name && (
{rule.ruleId} <span className="text-muted-foreground font-mono text-[10px]">
{rule.tableName && ` - ${rule.tableName}`} {field.name}
</span> </span>
)}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
@ -1462,13 +1261,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{numberingRules.length === 0 && !numberingRulesLoading && ( )}
<p className="mt-1 text-xs text-orange-600"> {hasRestAPISource && (
. . <p className="mt-1 text-xs text-gray-500">API JSON의 </p>
</p> )}
)} </div>
</div>
)}
<div className="flex items-center justify-center py-1"> <div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-green-600" /> <ArrowRight className="h-4 w-4 text-green-600" />
@ -1603,6 +1400,18 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={mapping.staticValue || ""}
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
placeholder="소스 필드 대신 고정 값 사용"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div> </div>
</div> </div>
))} ))}
@ -1619,8 +1428,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
{/* 안내 */} {/* 안내 */}
<div className="rounded bg-green-50 p-3 text-xs text-green-700"> <div className="rounded bg-green-50 p-3 text-xs text-green-700">
<p> .</p> .
<p className="mt-1"> 방식: 소스 ( ) / ( ) / ( )</p> <br />
💡 .
</div> </div>
</div> </div>
</div> </div>

View File

@ -302,9 +302,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
// 현재 모드에 따라 표시할 메뉴 결정 // 현재 모드에 따라 표시할 메뉴 결정
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시 // 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
const currentMenus = isAdminMode ? adminMenus : userMenus; const currentMenus = isAdminMode ? adminMenus : userMenus;
@ -461,15 +458,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
); );
} }
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
if (isPreviewMode) {
return (
<div className="h-screen w-full overflow-auto bg-white p-4">
{children}
</div>
);
}
// UI 변환된 메뉴 데이터 // UI 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -16,14 +15,6 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
import { ProfileFormData } from "@/types/profile"; import { ProfileFormData } from "@/types/profile";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { VehicleRegisterData } from "@/lib/api/driver"; import { VehicleRegisterData } from "@/lib/api/driver";
import { apiClient } from "@/lib/api/client";
// 언어 정보 타입
interface LanguageInfo {
langCode: string;
langName: string;
langNative: string;
}
// 운전자 정보 타입 // 운전자 정보 타입
export interface DriverInfo { export interface DriverInfo {
@ -157,46 +148,6 @@ export function ProfileModal({
onSave, onSave,
onAlertClose, onAlertClose,
}: ProfileModalProps) { }: ProfileModalProps) {
// 언어 목록 상태
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
// 언어 목록 로드
useEffect(() => {
const loadLanguages = async () => {
try {
const response = await apiClient.get("/multilang/languages");
if (response.data?.success && response.data?.data) {
// is_active가 'Y'인 언어만 필터링하고 정렬
const activeLanguages = response.data.data
.filter((lang: any) => lang.isActive === "Y" || lang.is_active === "Y")
.map((lang: any) => ({
langCode: lang.langCode || lang.lang_code,
langName: lang.langName || lang.lang_name,
langNative: lang.langNative || lang.lang_native,
}))
.sort((a: LanguageInfo, b: LanguageInfo) => {
// KR을 먼저 표시
if (a.langCode === "KR") return -1;
if (b.langCode === "KR") return 1;
return a.langCode.localeCompare(b.langCode);
});
setLanguages(activeLanguages);
}
} catch (error) {
console.error("언어 목록 로드 실패:", error);
// 기본값 설정
setLanguages([
{ langCode: "KR", langName: "Korean", langNative: "한국어" },
{ langCode: "US", langName: "English", langNative: "English" },
]);
}
};
if (isOpen) {
loadLanguages();
}
}, [isOpen]);
// 차량 상태 한글 변환 // 차량 상태 한글 변환
const getStatusLabel = (status: string | null) => { const getStatusLabel = (status: string | null) => {
switch (status) { switch (status) {
@ -342,15 +293,10 @@ export function ProfileModal({
<SelectValue placeholder="선택해주세요" /> <SelectValue placeholder="선택해주세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{languages.length > 0 ? ( <SelectItem value="KR"> (KR)</SelectItem>
languages.map((lang) => ( <SelectItem value="US">English (US)</SelectItem>
<SelectItem key={lang.langCode} value={lang.langCode}> <SelectItem value="JP"> (JP)</SelectItem>
{lang.langNative} ({lang.langCode}) <SelectItem value="CN"> (CN)</SelectItem>
</SelectItem>
))
) : (
<SelectItem value="KR"> (KR)</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ import {
Layout, Layout,
Monitor, Monitor,
Square, Square,
Languages,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -35,8 +34,6 @@ interface DesignerToolbarProps {
isSaving?: boolean; isSaving?: boolean;
showZoneBorders?: boolean; showZoneBorders?: boolean;
onToggleZoneBorders?: () => void; onToggleZoneBorders?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
} }
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
@ -53,8 +50,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
isSaving = false, isSaving = false,
showZoneBorders = true, showZoneBorders = true,
onToggleZoneBorders, onToggleZoneBorders,
onGenerateMultilang,
isGeneratingMultilang = false,
}) => { }) => {
return ( return (
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm"> <div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
@ -231,20 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
<div className="h-6 w-px bg-gray-300" /> <div className="h-6 w-px bg-gray-300" />
{onGenerateMultilang && (
<Button
variant="outline"
size="sm"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
className="flex items-center space-x-1"
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
>
<Languages className="h-4 w-4" />
<span className="hidden sm:inline">{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2"> <Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span> <span>{isSaving ? "저장 중..." : "저장"}</span>

View File

@ -309,10 +309,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 그룹 데이터 조회 함수 // 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => { const loadGroupData = async () => {
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) { if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
// console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
return; return;
} }
try { try {
// console.log("🔍 그룹 데이터 조회 시작:", {
// tableName: modalState.tableName,
// groupByColumns: modalState.groupByColumns,
// editData: modalState.editData,
// });
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001") // 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
const groupValues: Record<string, any> = {}; const groupValues: Record<string, any> = {};
modalState.groupByColumns.forEach((column) => { modalState.groupByColumns.forEach((column) => {
@ -322,9 +329,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}); });
if (Object.keys(groupValues).length === 0) { if (Object.keys(groupValues).length === 0) {
// console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
return; return;
} }
// console.log("🔍 그룹 조회 요청:", {
// tableName: modalState.tableName,
// groupValues,
// });
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용) // 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, { const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
@ -334,19 +347,23 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
enableEntityJoin: true, enableEntityJoin: true,
}); });
// console.log("🔍 그룹 조회 응답:", response);
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환 // entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
const dataArray = Array.isArray(response) ? response : response?.data || []; const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) { if (dataArray.length > 0) {
// console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
setGroupData(dataArray); setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`); toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
} else { } else {
console.warn("그룹 데이터가 없습니다:", response);
setGroupData([modalState.editData]); // 기본값: 선택된 행만 setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]); setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
} }
} catch (error: any) { } catch (error: any) {
console.error("그룹 데이터 조회 오류:", error); console.error("그룹 데이터 조회 오류:", error);
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다."); toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
setGroupData([modalState.editData]); // 기본값: 선택된 행만 setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]); setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
@ -654,11 +671,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.log("🗑️ 품목 삭제:", deletedItem); console.log("🗑️ 품목 삭제:", deletedItem);
try { try {
// screenId 전달하여 제어관리 실행 가능하도록 함
const response = await dynamicFormApi.deleteFormDataFromTable( const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id, deletedItem.id,
screenData.screenInfo.tableName, screenData.screenInfo.tableName,
modalState.screenId || screenData.screenInfo?.id,
); );
if (response.success) { if (response.success) {
@ -1026,18 +1041,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined; const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용) // 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인 // 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
const hasUniversalFormModal = screenData.components.some( const hasUniversalFormModal = screenData.components.some(
(c) => { (c) => {
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용 // 최상위에 universal-form-modal이 있는 경우
if (c.componentType === "universal-form-modal") return true; if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false; return false;
} }
); );
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가 // 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = { const enrichedFormData = {
@ -1079,9 +1093,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!, id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName, tableName: screenData.screenInfo?.tableName,
}} }}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용 // 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용 // ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={shouldUseEditModalSave ? handleSave : undefined} onSave={hasUniversalFormModal ? undefined : handleSave}
isInModal={true} isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달 // 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp} groupedData={groupedDataProp}

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import React, { useState, useEffect, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@ -43,7 +42,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client"; import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -101,7 +100,11 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
const isDisabled = !parentValue || loading; const isDisabled = !parentValue || loading;
return ( return (
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}> <Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className={className}> <SelectTrigger className={className}>
{loading ? ( {loading ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -184,17 +187,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용) const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치 const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
const searchParams = useSearchParams();
const menuObjid = useMemo(() => {
// 1. ScreenContext에서 가져오기
if (screenContext?.menuObjid) return screenContext.menuObjid;
// 2. URL 쿼리에서 가져오기
const urlMenuObjid = searchParams.get("menuObjid");
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
}, [screenContext?.menuObjid, searchParams]);
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({}); const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@ -206,7 +199,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const hasInitializedWidthsRef = useRef(false); const hasInitializedWidthsRef = useRef(false);
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({}); const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const isResizingRef = useRef(false); const isResizingRef = useRef(false);
// TableOptions 상태 // TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]); const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]); const [grouping, setGrouping] = useState<string[]>([]);
@ -243,19 +236,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑 const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}}) // 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState< const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
Record<string, Record<string, { label: string; color?: string }>>
>({});
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
// 테이블 등록 (Context에 등록) // 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`; const tableId = `datatable-${component.id}`;
useEffect(() => { useEffect(() => {
if (!component.tableName || !component.columns) return; if (!component.tableName || !component.columns) return;
registerTable({ registerTable({
tableId, tableId,
label: component.title || "데이터 테이블", label: component.title || "데이터 테이블",
@ -332,7 +320,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
useEffect(() => { useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => { const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {}; const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인 // 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) { if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", { console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
@ -377,10 +365,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) { for (const col of categoryColumns) {
try { try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get( const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`, `/table-categories/${component.tableName}/${col.columnName}/values`
); );
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
@ -393,7 +379,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}; };
}); });
mappings[col.columnName] = mapping; mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid }); console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
} }
} catch (error) { } catch (error) {
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error); console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
@ -408,7 +394,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}; };
loadCategoryMappings(); loadCategoryMappings();
}, [component.tableName, component.columns, getColumnWebType, menuObjid]); }, [component.tableName, component.columns, getColumnWebType]);
// 파일 상태 확인 함수 // 파일 상태 확인 함수
const checkFileStatus = useCallback( const checkFileStatus = useCallback(
@ -597,13 +583,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 없으면 테이블 타입 관리에서 설정된 값 찾기 // 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName); const tableColumn = tableColumns.find((col) => col.columnName === columnName);
// input_type 우선 사용 (category 등) // input_type 우선 사용 (category 등)
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType; const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
if (inputType) { if (inputType) {
return inputType; return inputType;
} }
// 없으면 webType 사용 // 없으면 webType 사용
return tableColumn?.webType || "text"; return tableColumn?.webType || "text";
}, },
@ -710,19 +696,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
let linkedFilterValues: Record<string, any> = {}; let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) { if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || []; const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some( hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => (filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName, filter.targetColumn === component.tableName
); );
// 좌측 데이터 선택 여부 확인 // 좌측 데이터 선택 여부 확인
hasSelectedLeftData = hasSelectedLeftData = splitPanelContext.selectedLeftData &&
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues(); linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
const tableSpecificFilters: Record<string, any> = {}; const tableSpecificFilters: Record<string, any> = {};
@ -741,7 +727,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
} }
linkedFilterValues = tableSpecificFilters; linkedFilterValues = tableSpecificFilters;
} }
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음) // → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
@ -753,9 +739,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false); setLoading(false);
return; return;
} }
// 🆕 RelatedDataButtons 필터 적용 // 🆕 RelatedDataButtons 필터 적용
const relatedButtonFilterValues: Record<string, any> = {}; let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) { if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue; relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
} }
@ -766,16 +752,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
...linkedFilterValues, ...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가 ...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
}; };
console.log("🔍 데이터 조회 시작:", { console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName, tableName: component.tableName,
page, page,
pageSize, pageSize,
linkedFilterValues, linkedFilterValues,
relatedButtonFilterValues, relatedButtonFilterValues,
mergedSearchParams, mergedSearchParams,
}); });
const result = await tableTypeApi.getTableData(component.tableName, { const result = await tableTypeApi.getTableData(component.tableName, {
page, page,
size: pageSize, size: pageSize,
@ -783,11 +769,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달 autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
}); });
console.log("✅ 데이터 조회 완료:", { console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName, tableName: component.tableName,
dataLength: result.data.length, dataLength: result.data.length,
total: result.total, total: result.total,
page: result.page, page: result.page
}); });
setData(result.data); setData(result.data);
@ -795,45 +781,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setTotalPages(result.totalPages); setTotalPages(result.totalPages);
setCurrentPage(result.page); setCurrentPage(result.page);
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
const detectAndLoadCategoryLabels = async () => {
const categoryCodes = new Set<string>();
result.data.forEach((row: Record<string, any>) => {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
});
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes);
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
const newLabels = {
...prev,
...response.data.data,
};
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
return newLabels;
});
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
detectAndLoadCategoryLabels();
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별) // 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => { const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
const primaryKeyField = Object.keys(rowData)[0]; const primaryKeyField = Object.keys(rowData)[0];
@ -969,18 +916,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
try { try {
const columns = await tableTypeApi.getColumns(component.tableName); const columns = await tableTypeApi.getColumns(component.tableName);
setTableColumns(columns); setTableColumns(columns);
// 🆕 전체 컬럼 목록 설정 // 🆕 전체 컬럼 목록 설정
const columnNames = columns.map((col) => col.columnName); const columnNames = columns.map(col => col.columnName);
setAllAvailableColumns(columnNames); setAllAvailableColumns(columnNames);
// 🆕 컬럼명 -> 라벨 매핑 생성 // 🆕 컬럼명 -> 라벨 매핑 생성
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
columns.forEach((col) => { columns.forEach(col => {
labels[col.columnName] = col.displayName || col.columnName; labels[col.columnName] = col.displayName || col.columnName;
}); });
setColumnLabels(labels); setColumnLabels(labels);
// 🆕 localStorage에서 필터 설정 복원 // 🆕 localStorage에서 필터 설정 복원
if (user?.userId && component.componentId) { if (user?.userId && component.componentId) {
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
@ -1036,31 +983,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
); );
// 행 선택 핸들러 // 행 선택 핸들러
const handleRowSelect = useCallback( const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
(rowIndex: number, isSelected: boolean) => { setSelectedRows((prev) => {
setSelectedRows((prev) => { const newSet = new Set(prev);
const newSet = new Set(prev); if (isSelected) {
if (isSelected) { newSet.add(rowIndex);
newSet.add(rowIndex); } else {
} else { newSet.delete(rowIndex);
newSet.delete(rowIndex);
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
} }
}, return newSet;
[data, splitPanelContext, splitPanelPosition], });
);
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
}, [data, splitPanelContext, splitPanelPosition]);
// 전체 선택/해제 핸들러 // 전체 선택/해제 핸들러
const handleSelectAll = useCallback( const handleSelectAll = useCallback(
@ -1642,7 +1586,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
); );
} }
// 상세 설정에서 옵션 목록 가져오기 // 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || []; const options = detailSettings?.options || [];
if (options.length > 0) { if (options.length > 0) {
@ -1769,9 +1713,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": { case "category": {
// 카테고리 셀렉트 (동적 import) // 카테고리 셀렉트 (동적 import)
const { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return ( return (
<div> <div>
<CategorySelectComponent <CategorySelectComponent
@ -1899,7 +1841,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
); );
} }
// 상세 설정에서 옵션 목록 가져오기 // 상세 설정에서 옵션 목록 가져오기
const optionsAdd = detailSettings?.options || []; const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) { if (optionsAdd.length > 0) {
@ -2071,9 +2013,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": { case "category": {
// 카테고리 셀렉트 (동적 import) // 카테고리 셀렉트 (동적 import)
const { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return ( return (
<div> <div>
<CategorySelectComponent <CategorySelectComponent
@ -2211,7 +2151,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const actualWebType = getColumnWebType(column.columnName); const actualWebType = getColumnWebType(column.columnName);
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함) // 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn; const isFileColumn =
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리) // 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) { if (isFileColumn && rowData) {
@ -2256,25 +2197,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": { case "category": {
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원) // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return ""; if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시 // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value); const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color; const displayColor = categoryData?.color;
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) { if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>; return <span className="text-sm">{displayLabel}</span>;
} }
return ( return (
<Badge <Badge
style={{ style={{
backgroundColor: displayColor, backgroundColor: displayColor,
borderColor: displayColor, borderColor: displayColor
}} }}
className="text-white" className="text-white"
> >
{displayLabel} {displayLabel}
@ -2314,41 +2255,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
} }
break; break;
default: { default:
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값) return String(value);
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
for (const columnName of Object.keys(categoryMappings)) {
const mapping = categoryMappings[columnName];
const categoryData = mapping?.[strValue];
if (categoryData) {
// 색상이 있으면 배지로, 없으면 텍스트로 표시
if (categoryData.color && categoryData.color !== "none") {
return (
<Badge
style={{
backgroundColor: categoryData.color,
borderColor: categoryData.color,
}}
className="text-white"
>
{categoryData.label}
</Badge>
);
}
return <span className="text-sm">{categoryData.label}</span>;
}
}
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
const cachedLabel = categoryCodeLabels[strValue];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
return strValue;
}
} }
return String(value); return String(value);
@ -2484,12 +2392,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.length > 0 ? ( {visibleColumns.length > 0 ? (
<> <>
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm"> <div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
<Table style={{ tableLayout: "fixed" }}> <Table style={{ tableLayout: 'fixed' }}>
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b"> <TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
<TableRow> <TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} {/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && ( {component.enableDelete && (
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}> <TableHead
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<Checkbox <Checkbox
checked={selectedRows.size === data.length && data.length > 0} checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
@ -2498,74 +2409,74 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
)} )}
{visibleColumns.map((column: DataTableColumn, columnIndex) => { {visibleColumns.map((column: DataTableColumn, columnIndex) => {
const columnWidth = columnWidths[column.id]; const columnWidth = columnWidths[column.id];
return ( return (
<TableHead <TableHead
key={column.id} key={column.id}
ref={(el) => (columnRefs.current[column.id] = el)} ref={(el) => (columnRefs.current[column.id] = el)}
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none" className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
style={{ style={{
width: columnWidth ? `${columnWidth}px` : undefined, width: columnWidth ? `${columnWidth}px` : undefined,
userSelect: "none", userSelect: 'none'
}} }}
> >
{column.label} {column.label}
{/* 리사이즈 핸들 */} {/* 리사이즈 핸들 */}
{columnIndex < visibleColumns.length - 1 && ( {columnIndex < visibleColumns.length - 1 && (
<div <div
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500" className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }} style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const thElement = columnRefs.current[column.id]; const thElement = columnRefs.current[column.id];
if (!thElement) return; if (!thElement) return;
isResizingRef.current = true; isResizingRef.current = true;
const startX = e.clientX; const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth; const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지 // 드래그 중 텍스트 선택 방지
document.body.style.userSelect = "none"; document.body.style.userSelect = 'none';
document.body.style.cursor = "col-resize"; document.body.style.cursor = 'col-resize';
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault(); moveEvent.preventDefault();
const diff = moveEvent.clientX - startX; const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff); const newWidth = Math.max(80, startWidth + diff);
// 직접 DOM 스타일 변경 (리렌더링 없음) // 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) { if (thElement) {
thElement.style.width = `${newWidth}px`; thElement.style.width = `${newWidth}px`;
} }
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
// 최종 너비를 state에 저장 // 최종 너비를 state에 저장
if (thElement) { if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth); const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth })); setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
} }
// 텍스트 선택 복원 // 텍스트 선택 복원
document.body.style.userSelect = ""; document.body.style.userSelect = '';
document.body.style.cursor = ""; document.body.style.cursor = '';
// 약간의 지연 후 리사이즈 플래그 해제 // 약간의 지연 후 리사이즈 플래그 해제
setTimeout(() => { setTimeout(() => {
isResizingRef.current = false; isResizingRef.current = false;
}, 100); }, 100);
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
document.addEventListener("mousemove", handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}} }}
/> />
)} )}
@ -2593,7 +2504,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100"> <TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */} {/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && ( {component.enableDelete && (
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}> <TableCell
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<Checkbox <Checkbox
checked={selectedRows.has(rowIndex)} checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)} onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
@ -2603,10 +2517,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.map((column: DataTableColumn) => { {visibleColumns.map((column: DataTableColumn) => {
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal"; const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
return ( return (
<TableCell <TableCell
key={column.id} key={column.id}
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900" className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
style={{ textAlign: isNumeric ? "right" : "left" }} style={{ textAlign: isNumeric ? 'right' : 'left' }}
> >
{formatCellValue(row[column.columnName], column, row)} {formatCellValue(row[column.columnName], column, row)}
</TableCell> </TableCell>

View File

@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
case "entity": { case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent; const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined; return applyStyles(
<DynamicWebTypeRenderer
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { webType="entity"
componentId: widget.id, config={widget.webTypeConfig}
widgetType: widget.widgetType, props={{
config, component: widget,
appliedSettings: { value: currentValue,
entityName: config?.entityName, onChange: (value: any) => updateFormData(fieldName, value),
displayField: config?.displayField, onFormDataChange: updateFormData,
valueField: config?.valueField, formData: formData,
multiple: config?.multiple, readonly: readonly,
defaultValue: config?.defaultValue, required: required,
}, placeholder: widget.placeholder || "엔티티를 선택하세요",
}); isInteractive: true,
className: "w-full h-full",
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요..."; }}
const defaultOptions = [ />,
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
return (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger
className="w-full"
style={{ height: "100%" }}
style={{
...comp.style,
width: "100%",
height: "100%",
}}
>
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
); );
} }
@ -1909,27 +1876,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
}; };
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return applyStyles( return applyStyles(
<button <Button
onClick={handleButtonClick} onClick={handleButtonClick}
disabled={readonly} disabled={readonly}
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${ size="sm"
hasCustomColors variant={config?.variant || "default"}
? '' className="w-full"
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{ style={{
height: "100%", height: "100%",
// 설정값이 있으면 우선 적용
backgroundColor: config?.backgroundColor, backgroundColor: config?.backgroundColor,
color: config?.textColor, color: config?.textColor,
borderColor: config?.borderColor, borderColor: config?.borderColor,
}} }}
> >
{label || "버튼"} {label || "버튼"}
</button> </Button>
); );
} }

View File

@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isInteractive={true} isInteractive={true}
formData={formData} formData={formData}
originalData={originalData || undefined} originalData={originalData || undefined}
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}
@ -834,18 +835,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} }
}; };
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
return ( return (
<button <Button
onClick={handleClick} onClick={handleClick}
variant={(config?.variant as any) || "default"}
size={(config?.size as any) || "default"}
disabled={config?.disabled} disabled={config?.disabled}
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{ style={{
// 컴포넌트 스타일 적용 // 컴포넌트 스타일 적용
...comp.style, ...comp.style,
@ -858,7 +853,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}} }}
> >
{label || "버튼"} {label || "버튼"}
</button> </Button>
); );
}; };

File diff suppressed because it is too large Load Diff

View File

@ -637,28 +637,24 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
} }
} }
// 색상이 설정되어 있으면 variant 스타일을 무시하고 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return ( return (
<div className="relative"> <div className="relative">
<Button <Button
onClick={handleClick} onClick={handleClick}
disabled={isExecuting || disabled} disabled={isExecuting || disabled}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함 variant={config?.variant || "default"}
variant={hasCustomColors ? undefined : (config?.variant || "default")}
className={cn( className={cn(
"transition-all duration-200", "transition-all duration-200",
isExecuting && "cursor-wait opacity-75", isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent", backgroundJobs.size > 0 && "border-primary/20 bg-accent",
// 커스텀 색상이 없을 때만 기본 스타일 적용 config?.backgroundColor && { backgroundColor: config.backgroundColor },
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90", config?.textColor && { color: config.textColor },
config?.borderColor && { borderColor: config.borderColor },
)} )}
style={{ style={{
// 커스텀 색상이 있을 때만 인라인 스타일 적용 backgroundColor: config?.backgroundColor,
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }), color: config?.textColor,
...(config?.textColor && { color: config.textColor }), borderColor: config?.borderColor,
...(config?.borderColor && { borderColor: config.borderColor }),
}} }}
> >
{/* 메인 버튼 내용 */} {/* 메인 버튼 내용 */}

View File

@ -17,7 +17,6 @@ import {
File, File,
} from "lucide-react"; } from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
// 컴포넌트 렌더러들 자동 등록 // 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components"; import "@/lib/registry/components";
@ -130,9 +129,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onFormDataChange, onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백 onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
}) => { }) => {
// 🆕 화면 다국어 컨텍스트
const { getTranslatedText } = useScreenMultiLang();
const [actualHeight, setActualHeight] = React.useState<number | null>(null); const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null); const contentRef = React.useRef<HTMLDivElement>(null);
const lastUpdatedHeight = React.useRef<number | null>(null); const lastUpdatedHeight = React.useRef<number | null>(null);

View File

@ -78,7 +78,6 @@ import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel"; import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
import DesignerToolbar from "./DesignerToolbar"; import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel"; import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
@ -145,8 +144,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}, },
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
// 🆕 화면에 할당된 메뉴 OBJID // 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined); const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
@ -1450,101 +1447,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}, [selectedScreen, layout, screenResolution]); }, [selectedScreen, layout, screenResolution]);
// 다국어 자동 생성 핸들러
const handleGenerateMultilang = useCallback(async () => {
if (!selectedScreen?.screenId) {
toast.error("화면 정보가 없습니다.");
return;
}
setIsGeneratingMultilang(true);
try {
// 공통 유틸 사용하여 라벨 추출
const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import(
"@/lib/utils/multilangLabelExtractor"
);
const { apiClient } = await import("@/lib/api/client");
// 테이블별 컬럼 라벨 로드
const tableNames = extractTableNames(layout.components);
const columnLabelMap: Record<string, Record<string, string>> = {};
for (const tableName of tableNames) {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data?.success && response.data?.data) {
const columns = response.data.data.columns || response.data.data;
if (Array.isArray(columns)) {
columnLabelMap[tableName] = {};
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name || col.name;
const colLabel = col.displayName || col.columnLabel || col.column_label || colName;
if (colName) {
columnLabelMap[tableName][colName] = colLabel;
}
});
}
}
} catch (error) {
console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error);
}
}
// 라벨 추출 (다국어 설정과 동일한 로직)
const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap);
const labels = extractedLabels.map((l) => ({
componentId: l.componentId,
label: l.label,
type: l.type,
}));
if (labels.length === 0) {
toast.info("다국어로 변환할 라벨이 없습니다.");
setIsGeneratingMultilang(false);
return;
}
// API 호출
const { generateScreenLabelKeys } = await import("@/lib/api/multilang");
const response = await generateScreenLabelKeys({
screenId: selectedScreen.screenId,
menuObjId: menuObjid?.toString(),
labels,
});
if (response.success && response.data) {
// 자동 매핑 적용
const updatedComponents = applyMultilangMappings(layout.components, response.data);
// 레이아웃 업데이트
const updatedLayout = {
...layout,
components: updatedComponents,
screenResolution: screenResolution,
};
setLayout(updatedLayout);
// 자동 저장 (매핑 정보가 손실되지 않도록)
try {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`);
} catch (saveError) {
console.error("다국어 매핑 저장 실패:", saveError);
toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`);
}
} else {
toast.error(response.error?.details || "다국어 키 생성에 실패했습니다.");
}
} catch (error) {
console.error("다국어 생성 실패:", error);
toast.error("다국어 키 생성 중 오류가 발생했습니다.");
} finally {
setIsGeneratingMultilang(false);
}
}, [selectedScreen, layout, screenResolution, menuObjid]);
// 템플릿 드래그 처리 // 템플릿 드래그 처리
const handleTemplateDrop = useCallback( const handleTemplateDrop = useCallback(
(e: React.DragEvent, template: TemplateComponent) => { (e: React.DragEvent, template: TemplateComponent) => {
@ -4315,9 +4217,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onBack={onBackToList} onBack={onBackToList}
onSave={handleSave} onSave={handleSave}
isSaving={isSaving} isSaving={isSaving}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
/> />
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */} {/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@ -5100,42 +4999,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
screenId={selectedScreen.screenId} screenId={selectedScreen.screenId}
/> />
)} )}
{/* 다국어 설정 모달 */}
<MultilangSettingsModal
isOpen={showMultilangSettingsModal}
onClose={() => setShowMultilangSettingsModal(false)}
components={layout.components}
onSave={async (updates) => {
if (updates.length === 0) {
toast.info("저장할 변경사항이 없습니다.");
return;
}
try {
// 공통 유틸 사용하여 매핑 적용
const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor");
// 매핑 형식 변환
const mappings = updates.map((u) => ({
componentId: u.componentId,
keyId: u.langKeyId,
langKey: u.langKey,
}));
// 레이아웃 업데이트
const updatedComponents = applyMultilangMappings(layout.components, mappings);
setLayout((prev) => ({
...prev,
components: updatedComponents,
}));
toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`);
} catch (error) {
console.error("다국어 설정 저장 실패:", error);
toast.error("다국어 설정 저장 중 오류가 발생했습니다.");
}
}}
/>
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ScreenPreviewProvider> </ScreenPreviewProvider>

View File

@ -1,477 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown, Folder } from "lucide-react";
import { cn } from "@/lib/utils";
interface ScreenGroupModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
group?: ScreenGroup | null; // 수정 모드일 때 기존 그룹 데이터
}
export function ScreenGroupModal({
isOpen,
onClose,
onSuccess,
group,
}: ScreenGroupModalProps) {
const [currentCompanyCode, setCurrentCompanyCode] = useState<string>("");
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [formData, setFormData] = useState({
group_name: "",
group_code: "",
description: "",
display_order: 0,
target_company_code: "",
parent_group_id: null as number | null,
});
const [loading, setLoading] = useState(false);
const [companies, setCompanies] = useState<{ code: string; name: string }[]>([]);
const [availableParentGroups, setAvailableParentGroups] = useState<ScreenGroup[]>([]);
const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false);
// 그룹 경로 가져오기 (계층 구조 표시용)
const getGroupPath = (groupId: number): string => {
const grp = availableParentGroups.find((g) => g.id === groupId);
if (!grp) return "";
const path: string[] = [grp.group_name];
let currentGroup = grp;
while ((currentGroup as any).parent_group_id) {
const parent = availableParentGroups.find((g) => g.id === (currentGroup as any).parent_group_id);
if (parent) {
path.unshift(parent.group_name);
currentGroup = parent;
} else {
break;
}
}
return path.join(" > ");
};
// 그룹을 계층 구조로 정렬
const getSortedGroups = (): typeof availableParentGroups => {
const result: typeof availableParentGroups = [];
const addChildren = (parentId: number | null, level: number) => {
const children = availableParentGroups
.filter((g) => (g as any).parent_group_id === parentId)
.sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
for (const child of children) {
result.push({ ...child, group_level: level } as any);
addChildren(child.id, level + 1);
}
};
addChildren(null, 1);
return result;
};
// 현재 사용자 정보 로드
useEffect(() => {
const loadUserInfo = async () => {
try {
const response = await apiClient.get("/auth/me");
const result = response.data;
if (result.success && result.data) {
const companyCode = result.data.companyCode || result.data.company_code || "";
setCurrentCompanyCode(companyCode);
setIsSuperAdmin(companyCode === "*");
}
} catch (error) {
console.error("사용자 정보 로드 실패:", error);
}
};
if (isOpen) {
loadUserInfo();
}
}, [isOpen]);
// 회사 목록 로드 (최고 관리자만)
useEffect(() => {
if (isSuperAdmin && isOpen) {
const loadCompanies = async () => {
try {
const response = await apiClient.get("/admin/companies");
const result = response.data;
if (result.success && result.data) {
const companyList = result.data.map((c: any) => ({
code: c.company_code,
name: c.company_name,
}));
setCompanies(companyList);
}
} catch (error) {
console.error("회사 목록 로드 실패:", error);
}
};
loadCompanies();
}
}, [isSuperAdmin, isOpen]);
// 부모 그룹 목록 로드 (현재 회사의 대분류/중분류 그룹만)
useEffect(() => {
if (isOpen && currentCompanyCode) {
const loadParentGroups = async () => {
try {
const response = await apiClient.get(`/screen-groups/groups?size=1000`);
const result = response.data;
if (result.success && result.data) {
// 모든 그룹을 상위 그룹으로 선택 가능 (무한 중첩 지원)
setAvailableParentGroups(result.data);
}
} catch (error) {
console.error("부모 그룹 목록 로드 실패:", error);
}
};
loadParentGroups();
}
}, [isOpen, currentCompanyCode]);
// 그룹 데이터가 변경되면 폼 초기화
useEffect(() => {
if (currentCompanyCode) {
if (group) {
setFormData({
group_name: group.group_name || "",
group_code: group.group_code || "",
description: group.description || "",
display_order: group.display_order || 0,
target_company_code: group.company_code || currentCompanyCode,
parent_group_id: (group as any).parent_group_id || null,
});
} else {
setFormData({
group_name: "",
group_code: "",
description: "",
display_order: 0,
target_company_code: currentCompanyCode,
parent_group_id: null,
});
}
}
}, [group, isOpen, currentCompanyCode]);
const handleSubmit = async () => {
// 필수 필드 검증
if (!formData.group_name.trim()) {
toast.error("그룹명을 입력하세요");
return;
}
if (!formData.group_code.trim()) {
toast.error("그룹 코드를 입력하세요");
return;
}
setLoading(true);
try {
let response;
if (group) {
// 수정 모드
response = await updateScreenGroup(group.id, formData);
} else {
// 추가 모드
response = await createScreenGroup({
...formData,
is_active: "Y",
});
}
if (response.success) {
toast.success(group ? "그룹이 수정되었습니다" : "그룹이 추가되었습니다");
onSuccess();
onClose();
} else {
toast.error(response.message || "작업에 실패했습니다");
}
} catch (error: any) {
console.error("그룹 저장 실패:", error);
toast.error("그룹 저장에 실패했습니다");
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{group ? "그룹 수정" : "그룹 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 회사 선택 (최고 관리자만) */}
{isSuperAdmin && (
<div>
<Label htmlFor="target_company_code" className="text-xs sm:text-sm">
*
</Label>
<Select
value={formData.target_company_code}
onValueChange={(value) =>
setFormData({ ...formData, target_company_code: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="회사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{companies.map((company) => (
<SelectItem key={company.code} value={company.code}>
{company.name} ({company.code})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
)}
{/* 부모 그룹 선택 (하위 그룹 만들기) - 트리 구조 + 검색 */}
<div>
<Label htmlFor="parent_group_id" className="text-xs sm:text-sm">
()
</Label>
<Popover open={isParentGroupSelectOpen} onOpenChange={setIsParentGroupSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isParentGroupSelectOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{formData.parent_group_id === null
? "대분류로 생성"
: getGroupPath(formData.parent_group_id) || "그룹 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="그룹 검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-2 text-center">
</CommandEmpty>
<CommandGroup>
{/* 대분류로 생성 옵션 */}
<CommandItem
value="none"
onSelect={() => {
setFormData({
...formData,
parent_group_id: null,
// 대분류 선택 시 현재 회사 코드 유지
});
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === null ? "opacity-100" : "opacity-0"
)}
/>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
</CommandItem>
{/* 계층 구조로 그룹 표시 */}
{getSortedGroups().map((parentGroup) => (
<CommandItem
key={parentGroup.id}
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
onSelect={() => {
// 상위 그룹의 company_code로 자동 설정
const parentCompanyCode = parentGroup.company_code || formData.target_company_code;
setFormData({
...formData,
parent_group_id: parentGroup.id,
target_company_code: parentCompanyCode,
});
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.parent_group_id === parentGroup.id ? "opacity-100" : "opacity-0"
)}
/>
{/* 들여쓰기로 계층 표시 */}
<span
style={{ marginLeft: `${(((parentGroup as any).group_level || 1) - 1) * 16}px` }}
className="flex items-center"
>
<Folder className="mr-2 h-4 w-4 text-muted-foreground" />
{parentGroup.group_name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 그룹명 */}
<div>
<Label htmlFor="group_name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_name"
value={formData.group_name}
onChange={(e) =>
setFormData({ ...formData, group_name: e.target.value })
}
placeholder="그룹명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 그룹 코드 */}
<div>
<Label htmlFor="group_code" className="text-xs sm:text-sm">
*
</Label>
<Input
id="group_code"
value={formData.group_code}
onChange={(e) =>
setFormData({ ...formData, group_code: e.target.value })
}
placeholder="영문 대문자와 언더스코어로 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled={!!group} // 수정 모드일 때는 코드 변경 불가
/>
{group && (
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
)}
</div>
{/* 설명 */}
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="그룹에 대한 설명을 입력하세요"
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
/>
</div>
{/* 정렬 순서 */}
<div>
<Label htmlFor="display_order" className="text-xs sm:text-sm">
</Label>
<Input
id="display_order"
type="number"
value={formData.display_order}
onChange={(e) =>
setFormData({
...formData,
display_order: parseInt(e.target.value) || 0,
})
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -42,8 +42,6 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection"; import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
import { Layers } from "lucide-react";
import CreateScreenModal from "./CreateScreenModal"; import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal"; import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@ -95,11 +93,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [isCopyOpen, setIsCopyOpen] = useState(false); const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null); const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 그룹 필터 관련 상태
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 검색어 디바운스를 위한 타이머 ref // 검색어 디바운스를 위한 타이머 ref
const debounceTimer = useRef<NodeJS.Timeout | null>(null); const debounceTimer = useRef<NodeJS.Timeout | null>(null);
@ -190,25 +183,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
} }
}; };
// 화면 그룹 목록 로드
useEffect(() => {
loadGroups();
}, []);
const loadGroups = async () => {
try {
setLoadingGroups(true);
const response = await getScreenGroups();
if (response.success && response.data) {
setGroups(response.data);
}
} catch (error) {
console.error("그룹 목록 조회 실패:", error);
} finally {
setLoadingGroups(false);
}
};
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답) // 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
useEffect(() => { useEffect(() => {
// 이전 타이머 취소 // 이전 타이머 취소
@ -250,11 +224,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
params.companyCode = selectedCompanyCode; params.companyCode = selectedCompanyCode;
} }
// 그룹 필터
if (selectedGroupId !== "all") {
params.groupId = selectedGroupId;
}
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용 console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
const resp = await screenApi.getScreens(params); const resp = await screenApi.getScreens(params);
console.log("✅ 화면 목록 응답:", resp); // 디버깅용 console.log("✅ 화면 목록 응답:", resp); // 디버깅용
@ -287,7 +256,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return () => { return () => {
abort = true; abort = true;
}; };
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]); }, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
const filteredScreens = screens; // 서버 필터 기준 사용 const filteredScreens = screens; // 서버 필터 기준 사용
@ -702,25 +671,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div> </div>
)} )}
{/* 그룹 필터 */}
<div className="w-full sm:w-[180px]">
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
<SelectTrigger className="h-10 text-sm">
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="전체 그룹" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="ungrouped"></SelectItem>
{groups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.groupName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 검색 입력 */} {/* 검색 입력 */}
<div className="w-full sm:w-[400px]"> <div className="w-full sm:w-[400px]">
<div className="relative"> <div className="relative">

View File

@ -1,869 +0,0 @@
"use client";
import React, { useMemo, useState, useEffect } from "react";
import { Handle, Position } from "@xyflow/react";
import {
Monitor,
Database,
FormInput,
Table2,
LayoutDashboard,
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
export interface ScreenNodeData {
label: string;
subLabel?: string;
type: "screen" | "table" | "action";
tableName?: string;
isMain?: boolean;
// 레이아웃 요약 정보 (미리보기용)
layoutSummary?: ScreenLayoutSummary;
// 그룹 내 포커스 관련 속성
isInGroup?: boolean; // 그룹 모드인지
isFocused?: boolean; // 포커스된 화면인지
isFaded?: boolean; // 흑백 처리할지
screenRole?: string; // 화면 역할 (메인그리드, 등록폼 등)
}
// 필드 매핑 정보 (조인 관계 표시용)
export interface FieldMappingDisplay {
sourceField: string; // 메인 테이블 컬럼 (예: manager_id)
targetField: string; // 서브 테이블 컬럼 (예: user_id)
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명 (예: 담당자)
targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID)
sourceTable?: string; // 소스 테이블명 (필드 매핑에서 테이블 구분용)
}
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
export interface ReferenceInfo {
fromTable: string; // 참조하는 테이블명 (영문)
fromTableLabel?: string; // 참조하는 테이블 한글명
fromColumn: string; // 참조하는 컬럼명 (영문)
fromColumnLabel?: string; // 참조하는 컬럼 한글명
toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼)
toColumnLabel?: string; // 참조되는 컬럼 한글명
relationType: 'lookup' | 'join' | 'filter'; // 참조 유형
}
// 테이블 노드 데이터 인터페이스
export interface TableNodeData {
label: string;
subLabel?: string;
isMain?: boolean;
isFocused?: boolean; // 포커스된 테이블인지
isFaded?: boolean; // 흑백 처리할지
columns?: Array<{
name: string; // 표시용 이름 (한글명)
originalName?: string; // 원본 컬럼명 (영문, 필터링용)
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
}>;
// 포커스 시 강조할 컬럼 정보
highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명)
joinColumns?: string[]; // 조인에 사용되는 컬럼
joinColumnRefs?: Array<{ // 조인 컬럼의 참조 정보
column: string; // FK 컬럼명 (예: 'customer_id')
refTable: string; // 참조 테이블 (예: 'customer_mng')
refTableLabel?: string; // 참조 테이블 한글명 (예: '거래처 관리')
refColumn: string; // 참조 컬럼 (예: 'customer_code')
}>;
filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계)
// 필드 매핑 정보 (조인 관계 표시용)
fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
referencedBy?: ReferenceInfo[]; // 이 테이블을 참조하는 관계들
// 저장 관계 정보
saveInfos?: Array<{
saveType: string; // 'save' | 'edit' | 'delete' | 'transferData'
componentType: string; // 버튼 컴포넌트 타입
isMainTable: boolean; // 메인 테이블 저장인지
sourceScreenId?: number; // 어떤 화면에서 저장하는지
}>;
}
// ========== 유틸리티 함수 ==========
// 화면 타입별 아이콘
const getScreenTypeIcon = (screenType?: string) => {
switch (screenType) {
case "grid":
return <Table2 className="h-4 w-4" />;
case "dashboard":
return <LayoutDashboard className="h-4 w-4" />;
case "action":
return <MousePointer2 className="h-4 w-4" />;
default:
return <FormInput className="h-4 w-4" />;
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-blue-500";
}
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-blue-500"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
};
// 화면 타입별 라벨
const getScreenTypeLabel = (screenType?: string) => {
switch (screenType) {
case "grid":
return "그리드";
case "dashboard":
return "대시보드";
case "action":
return "액션";
default:
return "폼";
}
};
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const screenType = layoutSummary?.screenType || "form";
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-gray-300"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
: isFaded
? "border-gray-200 opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
}}
>
{/* Handles */}
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-slate-50 p-2">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-slate-200 bg-white px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-slate-500">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-slate-400">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
</div>
</div>
);
};
// ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-blue-500";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
}
return "bg-slate-100 border-slate-300";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: string }> = ({
layoutSummary,
screenType,
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-blue-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-slate-100" : "bg-white"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-slate-300/70" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-blue-500" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
<div className="h-2.5 w-4 rounded bg-slate-300" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 폼 화면 일러스트
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-slate-400" />
<div className="h-5 flex-1 rounded-md border border-slate-300 bg-white shadow-sm" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-slate-100 pt-3">
<div className="h-5 w-14 rounded-md bg-slate-300 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-blue-500 shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 대시보드 화면 일러스트
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-blue-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-blue-400" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-blue-400/80"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white p-3">
<div className="rounded-full bg-slate-100 p-4 text-slate-400">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-blue-500 shadow-sm" />
<div className="h-7 w-16 rounded-md bg-slate-300 shadow-sm" />
</div>
<div className="text-xs font-medium text-slate-400"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-gradient-to-b from-slate-50 to-white text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
</div>
);
};
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data;
// 강조할 컬럼 세트 (영문 컬럼명 기준)
const highlightSet = new Set(highlightedColumns || []);
const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼
const joinSet = new Set(joinColumns || []);
// 조인 컬럼 참조 정보 맵 생성 (column → { refTable, refTableLabel, refColumn })
const joinRefMap = new Map<string, { refTable: string; refTableLabel: string; refColumn: string }>();
if (joinColumnRefs) {
joinColumnRefs.forEach((ref) => {
joinRefMap.set(ref.column, {
refTable: ref.refTable,
refTableLabel: ref.refTableLabel || ref.refTable, // 한글명 (없으면 영문명)
refColumn: ref.refColumn
});
});
}
// 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName })
// 서브 테이블에서 targetField가 어떤 메인 테이블 컬럼(sourceField)과 연결되는지
const fieldMappingMap = new Map<string, { sourceField: string; sourceDisplayName: string }>();
if (fieldMappings) {
fieldMappings.forEach(mapping => {
fieldMappingMap.set(mapping.targetField, {
sourceField: mapping.sourceField,
// 한글명이 있으면 한글명, 없으면 영문명 사용
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
});
});
}
// 필터 소스 컬럼 세트 (메인 테이블에서 필터에 사용되는 컬럼)
const filterSourceSet = new Set(
referencedBy?.filter(r => r.relationType === 'filter').map(r => r.fromColumn) || []
);
// 포커스 모드: 사용 컬럼만 필터링하여 표시
// originalName (영문) 또는 name으로 매칭 시도
// 필터 컬럼(filterSet) 및 필터 소스 컬럼(filterSourceSet)도 포함하여 보라색으로 표시
const potentialFilteredColumns = columns?.filter(col => {
const colOriginal = col.originalName || col.name;
return highlightSet.has(colOriginal) || joinSet.has(colOriginal) || filterSet.has(colOriginal) || filterSourceSet.has(colOriginal);
}) || [];
// 정렬: 조인 컬럼 → 필터 컬럼/필터 소스 컬럼 → 사용 컬럼 순서
const sortedFilteredColumns = [...potentialFilteredColumns].sort((a, b) => {
const aOriginal = a.originalName || a.name;
const bOriginal = b.originalName || b.name;
const aIsJoin = joinSet.has(aOriginal);
const bIsJoin = joinSet.has(bOriginal);
const aIsFilter = filterSet.has(aOriginal) || filterSourceSet.has(aOriginal);
const bIsFilter = filterSet.has(bOriginal) || filterSourceSet.has(bOriginal);
// 조인 컬럼 우선
if (aIsJoin && !bIsJoin) return -1;
if (!aIsJoin && bIsJoin) return 1;
// 필터 컬럼/필터 소스 다음
if (aIsFilter && !bIsFilter) return -1;
if (!aIsFilter && bIsFilter) return 1;
// 나머지는 원래 순서 유지
return 0;
});
const hasActiveColumns = sortedFilteredColumns.length > 0;
// 필터 관계가 있는 테이블인지 확인 (마스터-디테일 필터링)
// - hasFilterRelation: 디테일 테이블 (WHERE 조건 대상) - filterColumns에 FK 컬럼이 있음
// - isFilterSource: 마스터 테이블 (필터 소스, WHERE 조건 제공) - 포커스된 화면의 메인 테이블이고 filterSourceSet에 컬럼이 있음
// 디테일 테이블: filterColumns(filterSet)에 FK 컬럼이 있고, 포커스된 화면의 메인이 아님
const hasFilterRelation = filterSet.size > 0 && !isFocused;
// 마스터 테이블: 포커스된 화면의 메인 테이블(isFocused)이고 filterSourceSet에 컬럼이 있음
const isFilterSource = isFocused && filterSourceSet.size > 0;
// 표시할 컬럼:
// - 포커스 시 (활성 컬럼 있음): 정렬된 컬럼만 표시
// - 비포커스 시: 최대 8개만 표시
const MAX_DEFAULT_COLUMNS = 8;
const allColumns = columns || [];
const displayColumns = hasActiveColumns
? sortedFilteredColumns
: allColumns.slice(0, MAX_DEFAULT_COLUMNS);
const remainingCount = hasActiveColumns
? 0
: Math.max(0, allColumns.length - MAX_DEFAULT_COLUMNS);
const totalCount = allColumns.length;
// 컬럼 수 기반 높이 계산 (DOM 측정 없이)
// - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px)
// - 컨테이너 패딩: p-1.5 = 12px (상하 합계)
// - 뱃지 높이: 약 26px (py-1 + text + gap)
const COLUMN_ROW_HEIGHT = 22;
const CONTAINER_PADDING = 12;
const BADGE_HEIGHT = 26;
const MAX_HEIGHT = 200; // 뱃지 포함 가능하도록 증가
// 뱃지가 표시될지 미리 계산 (필터/참조만, 저장은 헤더에 표시)
const hasFilterOrLookupBadge = referencedBy && referencedBy.some(r => r.relationType === 'filter' || r.relationType === 'lookup');
const hasBadge = hasFilterOrLookupBadge;
const calculatedHeight = useMemo(() => {
const badgeHeight = hasBadge ? BADGE_HEIGHT : 0;
const rawHeight = CONTAINER_PADDING + badgeHeight + (displayColumns.length * COLUMN_ROW_HEIGHT);
return Math.min(rawHeight, MAX_HEIGHT);
}, [displayColumns.length, hasBadge]);
// Debounce된 높이: 중간 값(늘어났다가 줄어드는 현상)을 무시하고 최종 값만 사용
// 듀얼 그리드에서 filterColumns와 joinColumns가 2단계로 업데이트되는 문제 해결
const [debouncedHeight, setDebouncedHeight] = useState(calculatedHeight);
useEffect(() => {
// 50ms 내에 다시 변경되면 이전 값 무시
const timer = setTimeout(() => {
setDebouncedHeight(calculatedHeight);
}, 50);
return () => clearTimeout(timer);
}, [calculatedHeight]);
// 저장 대상 여부
const hasSaveTarget = saveInfos && saveInfos.length > 0;
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
(hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 순수 포커스 (필터 관계 없음): 초록색
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
// 흐리게 처리
: isFaded
? "border-gray-200 opacity-60 bg-card"
// 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
// 색상/테두리/그림자만 transition (높이 제외)
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
}}
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
>
{/* 저장 대상: 테이블 바깥 왼쪽에 띄워진 막대기 (나타나기/사라지기 애니메이션) */}
<div
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
style={{
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
opacity: hasSaveTarget ? 1 : 0,
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'top',
pointerEvents: hasSaveTarget ? 'auto' : 'none',
}}
/>
{/* Handles */}
{/* top target: 화면 → 메인테이블 연결용 */}
<Handle
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
type="source"
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
type="target"
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
? "디테일 테이블 (WHERE 조건)"
: subLabel}
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
</span>
)}
</div>
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
// Debounce로 중간 값이 무시되므로 항상 부드러운 transition 적용 가능
transition: 'height 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* 필터링/참조 관계 뱃지 (컬럼 목록 영역 안에 포함, 저장은 헤더에 표시) */}
{hasBadge && (() => {
const filterRefs = referencedBy?.filter(r => r.relationType === 'filter') || [];
const lookupRefs = referencedBy?.filter(r => r.relationType === 'lookup') || [];
if (filterRefs.length === 0 && lookupRefs.length === 0) return null;
return (
<div className="flex items-center gap-1.5 px-2 py-1 mb-1.5 rounded border border-slate-300 bg-slate-50 text-[9px]">
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
<span></span>
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
</span>
)}
</div>
);
})()}
{displayColumns.length > 0 ? (
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
{displayColumns.map((col, idx) => {
const colOriginal = col.originalName || col.name;
const isJoinColumn = joinSet.has(colOriginal);
const isFilterColumn = filterSet.has(colOriginal); // 서브 테이블의 필터링 FK 컬럼
const isHighlighted = highlightSet.has(colOriginal);
// 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) - 서브 테이블용
const filterRefInfo = referencedBy?.find(
r => r.relationType === 'filter' && r.toColumn === colOriginal
);
// 메인 테이블에서 필터 소스로 사용되는 컬럼인지 (fromColumn과 일치)
const isFilterSourceColumn = filterSourceSet.has(colOriginal);
return (
<div
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-orange-100 border border-orange-300 shadow-sm"
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
: isHighlighted
? "bg-blue-100 border border-blue-300 shadow-sm"
: hasActiveColumns
? "bg-slate-100"
: "bg-slate-50 hover:bg-slate-100"
}`}
style={{
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-orange-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-blue-700"
: "text-slate-700"
}`}>
{col.name}
</span>
{/* 역할 태그 + 참조 관계 표시 */}
{isJoinColumn && (
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
</div>
)}
</div>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[9px] text-muted-foreground">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}
</span>
)}
</div>
{/* CSS 애니메이션 정의 */}
<style jsx>{`
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
};
// ========== 기존 호환성 유지용 ==========
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-white p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,296 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { ScreenDefinition } from "@/types/screen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Database,
Monitor,
ArrowRight,
Link2,
Table,
Columns,
ExternalLink,
Layers,
GitBranch
} from "lucide-react";
import { getFieldJoins, getDataFlows, getTableRelations, FieldJoin, DataFlow, TableRelation } from "@/lib/api/screenGroup";
import { screenApi } from "@/lib/api/screen";
interface ScreenRelationViewProps {
screen: ScreenDefinition | null;
}
export function ScreenRelationView({ screen }: ScreenRelationViewProps) {
const [loading, setLoading] = useState(false);
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
const [layoutInfo, setLayoutInfo] = useState<any>(null);
useEffect(() => {
const loadRelations = async () => {
if (!screen) {
setFieldJoins([]);
setDataFlows([]);
setTableRelations([]);
setLayoutInfo(null);
return;
}
try {
setLoading(true);
// 병렬로 데이터 로드
const [joinsRes, flowsRes, relationsRes, layoutRes] = await Promise.all([
getFieldJoins(screen.screenId),
getDataFlows(screen.screenId),
getTableRelations(screen.screenId),
screenApi.getLayout(screen.screenId).catch(() => null),
]);
if (joinsRes.success && joinsRes.data) {
setFieldJoins(joinsRes.data);
}
if (flowsRes.success && flowsRes.data) {
setDataFlows(flowsRes.data);
}
if (relationsRes.success && relationsRes.data) {
setTableRelations(relationsRes.data);
}
if (layoutRes) {
setLayoutInfo(layoutRes);
}
} catch (error) {
console.error("관계 정보 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadRelations();
}, [screen?.screenId]);
if (!screen) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Layers className="h-16 w-16 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"> </h3>
<p className="text-sm text-muted-foreground/70">
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
);
}
// 컴포넌트에서 사용하는 테이블 분석
const getUsedTables = () => {
const tables = new Set<string>();
if (screen.tableName) {
tables.add(screen.tableName);
}
if (layoutInfo?.components) {
layoutInfo.components.forEach((comp: any) => {
if (comp.properties?.tableName) {
tables.add(comp.properties.tableName);
}
if (comp.properties?.dataSource?.tableName) {
tables.add(comp.properties.dataSource.tableName);
}
});
}
return Array.from(tables);
};
const usedTables = getUsedTables();
return (
<div className="p-4 space-y-4 overflow-auto h-full">
{/* 화면 기본 정보 */}
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-12 h-12 rounded-lg bg-blue-500/10">
<Monitor className="h-6 w-6 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg truncate">{screen.screenName}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline">{screen.screenCode}</Badge>
<Badge variant="secondary">{screen.screenType}</Badge>
</div>
{screen.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{screen.description}
</p>
)}
</div>
</div>
<Separator />
{/* 연결된 테이블 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4 text-green-500" />
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{usedTables.length > 0 ? (
<div className="space-y-2">
{usedTables.map((tableName, index) => (
<div
key={index}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">{tableName}</span>
{tableName === screen.tableName && (
<Badge variant="default" className="text-xs ml-auto">
</Badge>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 필드 조인 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Link2 className="h-4 w-4 text-purple-500" />
{fieldJoins.length > 0 && (
<Badge variant="secondary" className="ml-auto">{fieldJoins.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{fieldJoins.length > 0 ? (
<div className="space-y-2">
{fieldJoins.map((join) => (
<div
key={join.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.sourceTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-blue-600">{join.sourceColumn}</span>
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="flex items-center gap-1">
<span className="font-mono text-xs">{join.targetTable}</span>
<span className="text-muted-foreground">.</span>
<span className="font-mono text-xs text-green-600">{join.targetColumn}</span>
</div>
<Badge variant="outline" className="ml-auto text-xs">
{join.joinType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 데이터 흐름 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<GitBranch className="h-4 w-4 text-orange-500" />
{dataFlows.length > 0 && (
<Badge variant="secondary" className="ml-auto">{dataFlows.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{dataFlows.length > 0 ? (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Monitor className="h-4 w-4 text-blue-500" />
<span className="truncate">{flow.flowName || "이름 없음"}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Monitor className="h-4 w-4 text-green-500" />
<span className="text-muted-foreground truncate">
#{flow.targetScreenId}
</span>
<Badge variant="outline" className="ml-auto text-xs">
{flow.flowType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 테이블 관계 */}
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Columns className="h-4 w-4 text-cyan-500" />
{tableRelations.length > 0 && (
<Badge variant="secondary" className="ml-auto">{tableRelations.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0">
{tableRelations.length > 0 ? (
<div className="space-y-2">
{tableRelations.map((relation) => (
<div
key={relation.id}
className="flex items-center gap-2 p-2 rounded-md bg-muted/50 text-sm"
>
<Table className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">{relation.parentTable}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs">{relation.childTable}</span>
<Badge variant="outline" className="ml-auto text-xs">
{relation.relationType}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"> </p>
)}
</CardContent>
</Card>
{/* 빠른 작업 */}
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground mb-2">
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Trash2, Plus } from "lucide-react";
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management"; import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management"; import { UnifiedColumnInfo } from "@/types/table-management";
import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { getCategoryValues } from "@/lib/api/tableCategoryValue";
@ -20,67 +19,6 @@ interface DataFilterConfigPanelProps {
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요) menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
} }
/**
*
*/
interface FilterItemCollapsibleProps {
filter: ColumnFilter;
index: number;
filterSummary: string;
onRemove: () => void;
children: React.ReactNode;
}
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
filter,
index,
filterSummary,
onRemove,
children,
}) => {
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-lg border p-2">
<CollapsibleTrigger asChild>
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
{/* 상단: 필터 번호 + 삭제 버튼 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{isOpen ? (
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
)}
<span className="text-muted-foreground text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 shrink-0 p-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 하단: 필터 요약 (전체 너비 사용) */}
<div className="mt-1 pl-4">
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
{filterSummary}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
</div>
</Collapsible>
);
};
/** /**
* *
* , , * , ,
@ -98,13 +36,13 @@ export function DataFilterConfigPanel({
menuObjid, menuObjid,
sampleColumns: columns.slice(0, 3), sampleColumns: columns.slice(0, 3),
}); });
const [localConfig, setLocalConfig] = useState<DataFilterConfig>( const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
config || { config || {
enabled: false, enabled: false,
filters: [], filters: [],
matchType: "all", matchType: "all",
}, }
); );
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록) // 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
@ -114,7 +52,7 @@ export function DataFilterConfigPanel({
useEffect(() => { useEffect(() => {
if (config) { if (config) {
setLocalConfig(config); setLocalConfig(config);
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드 // 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
config.filters?.forEach((filter) => { config.filters?.forEach((filter) => {
if (filter.valueType === "category" && filter.columnName) { if (filter.valueType === "category" && filter.columnName) {
@ -131,7 +69,7 @@ export function DataFilterConfigPanel({
return; // 이미 로드되었거나 로딩 중이면 스킵 return; // 이미 로드되었거나 로딩 중이면 스킵
} }
setLoadingCategories((prev) => ({ ...prev, [columnName]: true })); setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
try { try {
console.log("🔍 카테고리 값 로드 시작:", { console.log("🔍 카테고리 값 로드 시작:", {
@ -144,7 +82,7 @@ export function DataFilterConfigPanel({
tableName, tableName,
columnName, columnName,
false, // includeInactive false, // includeInactive
menuObjid, // 🆕 메뉴 OBJID 전달 menuObjid // 🆕 메뉴 OBJID 전달
); );
console.log("📦 카테고리 값 로드 응답:", response); console.log("📦 카테고리 값 로드 응답:", response);
@ -154,16 +92,16 @@ export function DataFilterConfigPanel({
value: item.valueCode, value: item.valueCode,
label: item.valueLabel, label: item.valueLabel,
})); }));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length }); console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues((prev) => ({ ...prev, [columnName]: values })); setCategoryValues(prev => ({ ...prev, [columnName]: values }));
} else { } else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response); console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
} }
} catch (error) { } catch (error) {
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error); console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally { } finally {
setLoadingCategories((prev) => ({ ...prev, [columnName]: false })); setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
} }
}; };
@ -207,7 +145,9 @@ export function DataFilterConfigPanel({
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => { const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)), filters: localConfig.filters.map((filter) =>
filter.id === filterId ? { ...filter, [field]: value } : filter
),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); onConfigChange(newConfig);
@ -238,7 +178,7 @@ export function DataFilterConfigPanel({
<> <>
{/* 테이블명 표시 */} {/* 테이블명 표시 */}
{tableName && ( {tableName && (
<div className="text-muted-foreground text-xs"> <div className="text-xs text-muted-foreground">
: <span className="font-medium">{tableName}</span> : <span className="font-medium">{tableName}</span>
</div> </div>
)} )}
@ -260,127 +200,235 @@ export function DataFilterConfigPanel({
)} )}
{/* 필터 목록 */} {/* 필터 목록 */}
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2"> <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => { {localConfig.filters.map((filter, index) => (
// 연산자 표시 텍스트 <div key={filter.id} className="rounded-lg border p-3 space-y-2">
const operatorLabels: Record<string, string> = { <div className="flex items-center justify-between mb-2">
equals: "=", <span className="text-xs font-medium text-muted-foreground">
not_equals: "!=", {index + 1}
greater_than: ">", </span>
less_than: "<", <Button
greater_than_or_equal: ">=", variant="ghost"
less_than_or_equal: "<=", size="sm"
between: "BETWEEN", className="h-6 w-6 p-0"
in: "IN", onClick={() => handleRemoveFilter(filter.id)}
not_in: "NOT IN", >
contains: "LIKE", <Trash2 className="h-3 w-3" />
starts_with: "시작", </Button>
ends_with: "끝", </div>
is_null: "IS NULL",
is_not_null: "IS NOT NULL",
date_range_contains: "기간 내",
};
// 컬럼 라벨 찾기 {/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
const columnLabel = {filter.operator !== "date_range_contains" && (
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName; <div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
// 필터 요약 텍스트 생성 {/* 연산자 선택 */}
const filterSummary = filter.columnName <div>
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${ <Label className="text-xs"></Label>
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value <Select
? ` ${filter.value}` value={filter.operator}
: "" onValueChange={(value: any) => {
}` // date_range_contains 선택 시 한 번에 모든 변경사항 적용
: "설정 필요"; if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
handleFilterChange(filter.id, "operator", value);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
return ( {/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
<FilterItemCollapsible {filter.operator === "date_range_contains" && (
key={filter.id} <>
filter={filter} <div className="col-span-2">
index={index} <p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
filterSummary={filterSummary} 💡 :
onRemove={() => handleRemoveFilter(filter.id)} <br /> NULL
> <br /> NULL
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */} <br />
{filter.operator !== "date_range_contains" && ( </p>
</div>
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"> </Label>
<Select <Select
value={filter.columnName} value={filter.rangeConfig?.startColumn || ""}
onValueChange={(value) => { onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value); const newRangeConfig = {
...filter.rangeConfig,
console.log("🔍 컬럼 선택:", { startColumn: value,
columnName: value, endColumn: filter.rangeConfig?.endColumn || "",
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
),
}; };
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}} }}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" /> <SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{columns.map((col) => ( {columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName} {col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)} <div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 연산자 선택 */} {/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Select <Select
value={filter.operator} value={filter.valueType}
onValueChange={(value: any) => { onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용 // dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "date_range_contains") { if (value === "dynamic" && filter.operator === "date_range_contains") {
const newConfig = { const newConfig = {
...localConfig, ...localConfig,
filters: localConfig.filters.map((f) => filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f, f.id === filter.id
? { ...f, valueType: value, value: "TODAY" }
: f
), ),
}; };
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); onConfigChange(newConfig);
} else { } else {
handleFilterChange(filter.id, "operator", value); // static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} }
}} }}
> >
@ -388,240 +436,106 @@ export function DataFilterConfigPanel({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="equals"> (=)</SelectItem> <SelectItem value="static"> </SelectItem>
<SelectItem value="not_equals"> ()</SelectItem> {filter.operator === "date_range_contains" && (
<SelectItem value="greater_than"> (&gt;)</SelectItem> <SelectItem value="dynamic"> ( )</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem> )}
<SelectItem value="greater_than_or_equal"> ()</SelectItem> {isCategoryOrCodeColumn(filter.columnName) && (
<SelectItem value="less_than_or_equal"> ()</SelectItem> <>
<SelectItem value="between"> (BETWEEN)</SelectItem> <SelectItem value="category"> </SelectItem>
<SelectItem value="in"> (IN)</SelectItem> <SelectItem value="code"> </SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem> </>
<SelectItem value="contains"> (LIKE %value%)</SelectItem> )}
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */} {/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator === "date_range_contains" && ( {filter.operator !== "is_null" &&
<> filter.operator !== "is_not_null" &&
<div className="col-span-2"> !(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs"> <div>
💡 : <Label className="text-xs"></Label>
<br /> NULL {/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
<br /> NULL {filter.valueType === "category" && categoryValues[filter.columnName] ? (
<br />
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select <Select
value={filter.valueType} value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value: any) => { onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
}}
> >
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue /> <SelectValue placeholder={
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="static"> </SelectItem> {categoryValues[filter.columnName].map((option) => (
{filter.operator === "date_range_contains" && ( <SelectItem key={option.value} value={option.value}>
<SelectItem value="dynamic"> ( )</SelectItem> {option.label}
)} </SelectItem>
{isCategoryOrCodeColumn(filter.columnName) && ( ))}
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> ) : filter.operator === "in" || filter.operator === "not_in" ? (
)} <Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */} onChange={(e) => {
{filter.operator !== "is_null" && const values = e.target.value.split(",").map((v) => v.trim());
filter.operator !== "is_not_null" && handleFilterChange(filter.id, "value", values);
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && ( }}
<div> placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
<Label className="text-xs"></Label> className="h-8 text-xs sm:h-10 sm:text-sm"
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */} />
{filter.valueType === "category" && categoryValues[filter.columnName] ? ( ) : filter.operator === "between" ? (
<Select <Input
value={Array.isArray(filter.value) ? filter.value[0] : filter.value} value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)} onChange={(e) => {
> const values = e.target.value.split("~").map((v) => v.trim());
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
<SelectValue }}
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"} placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
/> className="h-8 text-xs sm:h-10 sm:text-sm"
</SelectTrigger> />
<SelectContent> ) : (
{categoryValues[filter.columnName].map((option) => ( <Input
<SelectItem key={option.value} value={option.value}> type={filter.operator === "date_range_contains" ? "date" : "text"}
{option.label} value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
</SelectItem> onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
))} placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
</SelectContent> className="h-8 text-xs sm:h-10 sm:text-sm"
</Select> />
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(
filter.id,
"value",
values.length === 2 ? values : [values[0] || "", ""],
);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)} )}
<p className="text-[10px] text-muted-foreground mt-1">
{/* date_range_contains의 dynamic 타입 안내 */} {filter.valueType === "category" && categoryValues[filter.columnName]
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && ( ? "카테고리 값을 선택하세요"
<div className="rounded-md bg-blue-50 p-2"> : filter.operator === "in" || filter.operator === "not_in"
<p className="text-[10px] text-blue-700"> .</p> ? "여러 값은 쉼표(,)로 구분하세요"
</div> : filter.operator === "between"
)} ? "시작과 종료 값을 ~로 구분하세요"
</FilterItemCollapsible> : filter.operator === "date_range_contains"
); ? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
})} : "필터링할 값을 입력하세요"}
</p>
</div>
)}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700">
.
</p>
</div>
)}
</div>
))}
</div> </div>
{/* 필터 추가 버튼 */} {/* 필터 추가 버튼 */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 w-full text-xs sm:h-10 sm:text-sm" className="w-full h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter} onClick={handleAddFilter}
disabled={columns.length === 0} disabled={columns.length === 0}
> >
@ -630,10 +544,13 @@ export function DataFilterConfigPanel({
</Button> </Button>
{columns.length === 0 && ( {columns.length === 0 && (
<p className="text-muted-foreground text-center text-xs"> </p> <p className="text-xs text-muted-foreground text-center">
</p>
)} )}
</> </>
)} )}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,465 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
import {
getDataFlows,
createDataFlow,
updateDataFlow,
deleteDataFlow,
DataFlow,
} from "@/lib/api/screenGroup";
interface DataFlowPanelProps {
groupId?: number;
screenId?: number;
screens?: Array<{ screen_id: number; screen_name: string }>;
}
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
// 상태 관리
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
const [formData, setFormData] = useState({
source_screen_id: 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
// 데이터 로드
const loadDataFlows = useCallback(async () => {
setLoading(true);
try {
const response = await getDataFlows(groupId);
if (response.success && response.data) {
setDataFlows(response.data);
}
} catch (error) {
console.error("데이터 흐름 로드 실패:", error);
} finally {
setLoading(false);
}
}, [groupId]);
useEffect(() => {
loadDataFlows();
}, [loadDataFlows]);
// 모달 열기
const openModal = (flow?: DataFlow) => {
if (flow) {
setSelectedFlow(flow);
setFormData({
source_screen_id: flow.source_screen_id,
source_action: flow.source_action || "",
target_screen_id: flow.target_screen_id,
target_action: flow.target_action || "",
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
flow_type: flow.flow_type,
flow_label: flow.flow_label || "",
condition_expression: flow.condition_expression || "",
is_active: flow.is_active,
});
} else {
setSelectedFlow(null);
setFormData({
source_screen_id: screenId || 0,
source_action: "",
target_screen_id: 0,
target_action: "",
data_mapping: "",
flow_type: "unidirectional",
flow_label: "",
condition_expression: "",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.source_screen_id || !formData.target_screen_id) {
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
return;
}
try {
let dataMappingJson = null;
if (formData.data_mapping) {
try {
dataMappingJson = JSON.parse(formData.data_mapping);
} catch {
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
return;
}
}
const payload = {
group_id: groupId,
source_screen_id: formData.source_screen_id,
source_action: formData.source_action || null,
target_screen_id: formData.target_screen_id,
target_action: formData.target_action || null,
data_mapping: dataMappingJson,
flow_type: formData.flow_type,
flow_label: formData.flow_label || null,
condition_expression: formData.condition_expression || null,
is_active: formData.is_active,
};
let response;
if (selectedFlow) {
response = await updateDataFlow(selectedFlow.id, payload);
} else {
response = await createDataFlow(payload);
}
if (response.success) {
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
setIsModalOpen(false);
loadDataFlows();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
try {
const response = await deleteDataFlow(id);
if (response.success) {
toast.success("데이터 흐름이 삭제되었습니다.");
loadDataFlows();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
// 액션 옵션
const sourceActions = [
{ value: "click", label: "클릭" },
{ value: "submit", label: "제출" },
{ value: "select", label: "선택" },
{ value: "change", label: "변경" },
{ value: "doubleClick", label: "더블클릭" },
];
const targetActions = [
{ value: "open", label: "열기" },
{ value: "load", label: "로드" },
{ value: "refresh", label: "새로고침" },
{ value: "save", label: "저장" },
{ value: "filter", label: "필터" },
];
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
<RefreshCw className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
. (: 목록 )
</p>
{/* 흐름 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : dataFlows.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
{dataFlows.map((flow) => (
<div
key={flow.id}
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* 소스 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
</span>
{flow.source_action && (
<span className="text-muted-foreground">{flow.source_action}</span>
)}
</div>
{/* 화살표 */}
<div className="flex items-center gap-1 text-primary">
<ArrowRight className="h-4 w-4" />
{flow.flow_type === "bidirectional" && (
<ArrowRight className="h-4 w-4 rotate-180" />
)}
</div>
{/* 타겟 화면 */}
<div className="flex flex-col">
<span className="font-medium truncate max-w-[100px]">
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
</span>
{flow.target_action && (
<span className="text-muted-foreground">{flow.target_action}</span>
)}
</div>
{/* 라벨 */}
{flow.flow_label && (
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
{flow.flow_label}
</span>
)}
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(flow.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 소스 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.source_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.source_action}
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{sourceActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 타겟 화면 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Select
value={formData.target_screen_id.toString()}
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
{screens.map((screen) => (
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
{screen.screen_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.target_action}
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="액션 선택" />
</SelectTrigger>
<SelectContent>
{targetActions.map((action) => (
<SelectItem key={action.value} value={action.value}>
{action.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 흐름 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.flow_type}
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unidirectional"></SelectItem>
<SelectItem value="bidirectional"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.flow_label}
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
placeholder="예: 상세 보기"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 데이터 매핑 */}
<div>
<Label className="text-xs sm:text-sm"> (JSON)</Label>
<Textarea
value={formData.data_mapping}
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
placeholder='{"source_field": "target_field"}'
className="min-h-[80px] font-mono text-xs sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
{/* 조건식 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Input
value={formData.condition_expression}
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
placeholder="예: data.status === 'active'"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
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"
>
{selectedFlow ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,417 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react";
import {
getFieldJoins,
createFieldJoin,
updateFieldJoin,
deleteFieldJoin,
FieldJoin,
} from "@/lib/api/screenGroup";
interface FieldJoinPanelProps {
screenId: number;
componentId?: string;
layoutId?: number;
}
export default function FieldJoinPanel({ screenId, componentId, layoutId }: FieldJoinPanelProps) {
// 상태 관리
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedJoin, setSelectedJoin] = useState<FieldJoin | null>(null);
const [formData, setFormData] = useState({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
// 데이터 로드
const loadFieldJoins = useCallback(async () => {
if (!screenId) return;
setLoading(true);
try {
const response = await getFieldJoins(screenId);
if (response.success && response.data) {
// 현재 컴포넌트에 해당하는 조인만 필터링
const filtered = componentId
? response.data.filter(join => join.component_id === componentId)
: response.data;
setFieldJoins(filtered);
}
} catch (error) {
console.error("필드 조인 로드 실패:", error);
} finally {
setLoading(false);
}
}, [screenId, componentId]);
useEffect(() => {
loadFieldJoins();
}, [loadFieldJoins]);
// 모달 열기
const openModal = (join?: FieldJoin) => {
if (join) {
setSelectedJoin(join);
setFormData({
field_name: join.field_name || "",
save_table: join.save_table,
save_column: join.save_column,
join_table: join.join_table,
join_column: join.join_column,
display_column: join.display_column,
join_type: join.join_type,
filter_condition: join.filter_condition || "",
sort_column: join.sort_column || "",
sort_direction: join.sort_direction || "ASC",
is_active: join.is_active,
});
} else {
setSelectedJoin(null);
setFormData({
field_name: "",
save_table: "",
save_column: "",
join_table: "",
join_column: "",
display_column: "",
join_type: "LEFT",
filter_condition: "",
sort_column: "",
sort_direction: "ASC",
is_active: "Y",
});
}
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column || !formData.display_column) {
toast.error("필수 필드를 모두 입력해주세요.");
return;
}
try {
const payload = {
screen_id: screenId,
layout_id: layoutId,
component_id: componentId,
...formData,
};
let response;
if (selectedJoin) {
response = await updateFieldJoin(selectedJoin.id, payload);
} else {
response = await createFieldJoin(payload);
}
if (response.success) {
toast.success(selectedJoin ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
setIsModalOpen(false);
loadFieldJoins();
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm("이 조인 설정을 삭제하시겠습니까?")) return;
try {
const response = await deleteFieldJoin(id);
if (response.success) {
toast.success("조인 설정이 삭제되었습니다.");
loadFieldJoins();
} else {
toast.error(response.message || "삭제에 실패했습니다.");
}
} catch (error) {
toast.error("삭제 중 오류가 발생했습니다.");
}
};
return (
<div className="space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold"> </h3>
</div>
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
<Plus className="h-3 w-3" />
</Button>
</div>
{/* 설명 */}
<p className="text-xs text-muted-foreground">
.
</p>
{/* 조인 목록 */}
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : fieldJoins.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
<Database className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> .</TableHead>
<TableHead className="h-8 text-xs"> </TableHead>
<TableHead className="h-8 w-[60px] text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fieldJoins.map((join) => (
<TableRow key={join.id} className="text-xs">
<TableCell className="py-2">
<span className="font-mono">{join.save_table}.{join.save_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.join_table}.{join.join_column}</span>
</TableCell>
<TableCell className="py-2">
<span className="font-mono">{join.display_column}</span>
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(join)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(join.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 추가/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{selectedJoin ? "조인 설정 수정" : "조인 설정 추가"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 필드명 */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.field_name}
onChange={(e) => setFormData({ ...formData, field_name: e.target.value })}
placeholder="화면에 표시될 필드명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 저장 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_table}
onChange={(e) => setFormData({ ...formData, save_table: e.target.value })}
placeholder="예: work_orders"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.save_column}
onChange={(e) => setFormData({ ...formData, save_column: e.target.value })}
placeholder="예: item_code"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 조인 테이블/컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_table}
onChange={(e) => setFormData({ ...formData, join_table: e.target.value })}
placeholder="예: item_mng"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.join_column}
onChange={(e) => setFormData({ ...formData, join_column: e.target.value })}
placeholder="예: id"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{/* 표시 컬럼 */}
<div>
<Label className="text-xs sm:text-sm"> *</Label>
<Input
value={formData.display_column}
onChange={(e) => setFormData({ ...formData, display_column: e.target.value })}
placeholder="예: item_name (화면에 표시될 컬럼)"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 조인 타입/정렬 */}
<div className="grid grid-cols-3 gap-4">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.join_type}
onValueChange={(value) => setFormData({ ...formData, join_type: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEFT">LEFT JOIN</SelectItem>
<SelectItem value="INNER">INNER JOIN</SelectItem>
<SelectItem value="RIGHT">RIGHT JOIN</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.sort_column}
onChange={(e) => setFormData({ ...formData, sort_column: e.target.value })}
placeholder="예: name"
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.sort_direction}
onValueChange={(value) => setFormData({ ...formData, sort_direction: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ASC"></SelectItem>
<SelectItem value="DESC"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 필터 조건 */}
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Textarea
value={formData.filter_condition}
onChange={(e) => setFormData({ ...formData, filter_condition: e.target.value })}
placeholder="예: is_active = 'Y'"
className="min-h-[60px] font-mono text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
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"
>
{selectedJoin ? "수정" : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react"; import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react";
import { ScreenResolution } from "@/types/screen"; import { ScreenResolution } from "@/types/screen";
interface SlimToolbarProps { interface SlimToolbarProps {
@ -13,9 +13,6 @@ interface SlimToolbarProps {
onSave: () => void; onSave: () => void;
isSaving?: boolean; isSaving?: boolean;
onPreview?: () => void; onPreview?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
} }
export const SlimToolbar: React.FC<SlimToolbarProps> = ({ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -26,9 +23,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onSave, onSave,
isSaving = false, isSaving = false,
onPreview, onPreview,
onGenerateMultilang,
isGeneratingMultilang = false,
onOpenMultilangSettings,
}) => { }) => {
return ( return (
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm"> <div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
@ -76,29 +70,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
<span> </span> <span> </span>
</Button> </Button>
)} )}
{onGenerateMultilang && (
<Button
variant="outline"
onClick={onGenerateMultilang}
disabled={isGeneratingMultilang}
className="flex items-center space-x-2"
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
>
<Languages className="h-4 w-4" />
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
</Button>
)}
{onOpenMultilangSettings && (
<Button
variant="outline"
onClick={onOpenMultilangSettings}
className="flex items-center space-x-2"
title="다국어 키 연결 및 설정을 관리합니다"
>
<Settings2 className="h-4 w-4" />
<span> </span>
</Button>
)}
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2"> <Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span> <span>{isSaving ? "저장 중..." : "저장"}</span>

View File

@ -32,27 +32,14 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
} }
}; };
// 커스텀 색상 확인 (config 또는 style에서)
const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
const hasCustomColor = config?.textColor || style?.color;
const hasCustomColors = hasCustomBg || hasCustomColor;
// 실제 적용할 배경색과 글자색
const bgColor = config?.backgroundColor || style?.backgroundColor;
const textColor = config?.textColor || style?.color;
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단 // 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
if (isDesignMode) { if (isDesignMode) {
return ( return (
<div <div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파 onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${ className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
hasCustomColors ? '' : 'bg-blue-600 text-white'
} ${className || ""}`}
style={{ style={{
...style, ...style,
backgroundColor: bgColor,
color: textColor,
width: "100%", width: "100%",
height: "100%", height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시 cursor: "pointer", // 선택 가능하도록 포인터 표시
@ -69,13 +56,9 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
type="button" type="button"
onClick={handleClick} onClick={handleClick}
disabled={disabled || readonly} disabled={disabled || readonly}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${ className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
hasCustomColors ? '' : 'bg-blue-600 text-white hover:bg-blue-700'
} ${className || ""}`}
style={{ style={{
...style, ...style,
backgroundColor: bgColor,
color: textColor,
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}

View File

@ -14,7 +14,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue"; import { TableCategoryValue } from "@/types/tableCategoryValue";
// 기본 색상 팔레트 // 기본 색상 팔레트
@ -52,7 +51,6 @@ export const CategoryValueAddDialog: React.FC<
const [valueLabel, setValueLabel] = useState(""); const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState("none"); const [color, setColor] = useState("none");
const [continuousAdd, setContinuousAdd] = useState(false); // 연속 입력 체크박스
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성) // 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
const generateCode = (): string => { const generateCode = (): string => {
@ -62,12 +60,6 @@ export const CategoryValueAddDialog: React.FC<
return `CATEGORY_${timestamp}${random}`; return `CATEGORY_${timestamp}${random}`;
}; };
const resetForm = () => {
setValueLabel("");
setDescription("");
setColor("none");
};
const handleSubmit = () => { const handleSubmit = () => {
if (!valueLabel.trim()) { if (!valueLabel.trim()) {
return; return;
@ -85,28 +77,14 @@ export const CategoryValueAddDialog: React.FC<
isDefault: false, isDefault: false,
} as TableCategoryValue); } as TableCategoryValue);
// 연속 입력 체크되어 있으면 폼만 초기화하고 모달 유지 // 초기화
if (continuousAdd) { setValueLabel("");
resetForm(); setDescription("");
} else { setColor("none");
// 연속 입력 아니면 모달 닫기
resetForm();
onOpenChange(false);
}
};
const handleClose = () => {
resetForm();
onOpenChange(false);
}; };
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => { <Dialog open={open} onOpenChange={onOpenChange}>
if (!isOpen) {
resetForm();
}
onOpenChange(isOpen);
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg">
@ -187,42 +165,24 @@ export const CategoryValueAddDialog: React.FC<
</div> </div>
</div> </div>
<DialogFooter className="flex-col gap-3 sm:flex-row sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
{/* 연속 입력 체크박스 */} <Button
<div className="flex items-center gap-2 w-full sm:w-auto sm:mr-auto"> variant="outline"
<Checkbox onClick={() => onOpenChange(false)}
id="continuousAdd" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
checked={continuousAdd} >
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/> </Button>
<label <Button
htmlFor="continuousAdd" onClick={handleSubmit}
className="text-xs sm:text-sm text-muted-foreground cursor-pointer" disabled={!valueLabel.trim()}
> className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</label>
</div> </Button>
<div className="flex gap-2 w-full sm:w-auto">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!valueLabel.trim()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View File

@ -123,7 +123,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
if (response.success && response.data) { if (response.success && response.data) {
await loadCategoryValues(); await loadCategoryValues();
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어 setIsAddDialogOpen(false);
toast({ toast({
title: "성공", title: "성공",
description: "카테고리 값이 추가되었습니다", description: "카테고리 값이 추가되었습니다",
@ -142,7 +142,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "오류", title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다", description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive", variant: "destructive",
}); });
} }
}; };

View File

@ -16,9 +16,7 @@ import {
RepeaterItemData, RepeaterItemData,
RepeaterFieldDefinition, RepeaterFieldDefinition,
CalculationFormula, CalculationFormula,
SubDataState,
} from "@/types/repeater"; } from "@/types/repeater";
import { SubDataLookupPanel } from "@/lib/registry/components/repeater-field-group/SubDataLookupPanel";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint"; import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -70,12 +68,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
layout = "grid", // 기본값을 grid로 설정 layout = "grid", // 기본값을 grid로 설정
showDivider = true, showDivider = true,
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.", emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
subDataLookup,
} = config; } = config;
// 하위 데이터 조회 상태 관리 (각 항목별)
const [subDataStates, setSubDataStates] = useState<Map<number, SubDataState>>(new Map());
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제 // 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout; const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
@ -278,111 +272,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 드래그 앤 드롭 (순서 변경) // 드래그 앤 드롭 (순서 변경)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null); const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// 하위 데이터 선택 핸들러
const handleSubDataSelection = (itemIndex: number, selectedItem: any | null, maxValue: number | null) => {
console.log("[RepeaterInput] 하위 데이터 선택:", { itemIndex, selectedItem, maxValue });
// 상태 업데이트
setSubDataStates((prev) => {
const newMap = new Map(prev);
const currentState = newMap.get(itemIndex) || {
itemIndex,
data: [],
selectedItem: null,
isLoading: false,
error: null,
isExpanded: false,
};
newMap.set(itemIndex, {
...currentState,
selectedItem,
});
return newMap;
});
// 선택된 항목 정보를 item에 저장
if (selectedItem && subDataLookup) {
const newItems = [...items];
newItems[itemIndex] = {
...newItems[itemIndex],
_subDataSelection: selectedItem,
_subDataMaxValue: maxValue,
};
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) {
subDataLookup.lookup.displayColumns.forEach((col) => {
if (selectedItem[col] !== undefined) {
// 필드가 정의되어 있으면 복사
const fieldDef = fields.find((f) => f.name === col);
if (fieldDef || col.includes("_code") || col.includes("_id")) {
newItems[itemIndex][col] = selectedItem[col];
}
}
});
}
setItems(newItems);
// onChange 호출
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
: newItems;
onChange?.(dataWithMeta);
}
};
// 조건부 입력 활성화 여부 확인
const isConditionalInputEnabled = (itemIndex: number, fieldName: string): boolean => {
if (!subDataLookup?.enabled) return true;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return true;
const subState = subDataStates.get(itemIndex);
if (!subState?.selectedItem) return false;
const { requiredFields, requiredMode = "all" } = subDataLookup.selection;
if (!requiredFields || requiredFields.length === 0) return true;
if (requiredMode === "any") {
return requiredFields.some((field) => {
const value = subState.selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
} else {
return requiredFields.every((field) => {
const value = subState.selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
}
};
// 최대값 가져오기
const getMaxValueForField = (itemIndex: number, fieldName: string): number | null => {
if (!subDataLookup?.enabled) return null;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return null;
if (!subDataLookup.conditionalInput?.maxValueField) return null;
const subState = subDataStates.get(itemIndex);
if (!subState?.selectedItem) return null;
const maxVal = subState.selectedItem[subDataLookup.conditionalInput.maxValueField];
return typeof maxVal === "number" ? maxVal : parseFloat(maxVal) || null;
};
// 경고 임계값 체크
const checkWarningThreshold = (itemIndex: number, fieldName: string, value: number): boolean => {
if (!subDataLookup?.enabled) return false;
if (subDataLookup.conditionalInput?.targetField !== fieldName) return false;
const maxValue = getMaxValueForField(itemIndex, fieldName);
if (maxValue === null || maxValue === 0) return false;
const threshold = subDataLookup.conditionalInput?.warningThreshold ?? 90;
const percentage = (value / maxValue) * 100;
return percentage >= threshold;
};
const handleDragStart = (index: number) => { const handleDragStart = (index: number) => {
if (!allowReorder || readonly || disabled) return; if (!allowReorder || readonly || disabled) return;
setDraggedIndex(index); setDraggedIndex(index);
@ -500,26 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly; const isReadonly = disabled || readonly || field.readonly;
// 조건부 입력 비활성화 체크
const isConditionalDisabled =
subDataLookup?.enabled &&
subDataLookup.conditionalInput?.targetField === field.name &&
!isConditionalInputEnabled(itemIndex, field.name);
// 최대값 및 경고 체크
const maxValue = getMaxValueForField(itemIndex, field.name);
const numValue = parseFloat(value) || 0;
const showWarning = checkWarningThreshold(itemIndex, field.name, numValue);
const exceedsMax = maxValue !== null && numValue > maxValue;
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지 // "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = { const commonProps = {
value: value || "", value: value || "",
disabled: isReadonly || isConditionalDisabled, disabled: isReadonly,
placeholder: isConditionalDisabled ? "재고 선택 필요" : defaultPlaceholder, placeholder: defaultPlaceholder,
required: field.required, required: field.required,
}; };
@ -692,37 +569,23 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="number" type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min} min={field.validation?.min}
max={maxValue !== null ? maxValue : field.validation?.max} max={field.validation?.max}
className={cn("pr-1", exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")} className="pr-1"
/> />
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>} {value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
{exceedsMax && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div> </div>
); );
} }
return ( return (
<div className="relative min-w-[80px]"> <Input
<Input {...commonProps}
{...commonProps} type="number"
type="number" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} min={field.validation?.min}
min={field.validation?.min} max={field.validation?.max}
max={maxValue !== null ? maxValue : field.validation?.max} className="min-w-[80px]"
className={cn(exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")} />
/>
{exceedsMax && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div>
); );
case "email": case "email":
@ -891,9 +754,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 그리드/테이블 형식 렌더링 // 그리드/테이블 형식 렌더링
const renderGridLayout = () => { const renderGridLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return ( return (
<div className="bg-card"> <div className="bg-card">
<Table> <Table>
@ -915,83 +775,55 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{items.map((item, itemIndex) => { {items.map((item, itemIndex) => (
// 하위 데이터 조회용 연결 값 <TableRow
const linkValue = linkColumn ? item[linkColumn] : null; key={itemIndex}
className={cn(
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
return ( {/* 드래그 핸들 */}
<React.Fragment key={itemIndex}> {allowReorder && !readonly && !disabled && (
<TableRow <TableCell className="h-12 px-2.5 py-2 text-center">
className={cn( <GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
"bg-background hover:bg-muted/50 transition-colors", </TableCell>
draggedIndex === itemIndex && "opacity-50", )}
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{/* 래그 핸들 */} {/* 필드들 */}
{allowReorder && !readonly && !disabled && ( {fields.map((field) => (
<TableCell className="h-12 px-2.5 py-2 text-center"> <TableCell key={field.name} className="h-12 px-2.5 py-2">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" /> {renderField(field, itemIndex, item[field.name])}
</TableCell> </TableCell>
)} ))}
{/* 필드들 */} {/* 삭제 버튼 */}
{fields.map((field) => ( <TableCell className="h-12 px-2.5 py-2 text-center">
<TableCell key={field.name} className="h-12 px-2.5 py-2"> {!readonly && !disabled && (
{renderField(field, itemIndex, item[field.name])} <Button
</TableCell> type="button"
))} variant="ghost"
size="icon"
{/* 삭제 버튼 */} onClick={() => handleRemoveItem(itemIndex)}
<TableCell className="h-12 px-2.5 py-2 text-center"> className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
{!readonly && !disabled && ( title="항목 제거"
<Button >
type="button" <X className="h-4 w-4" />
variant="ghost" </Button>
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
}
className="px-2.5 py-2"
>
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</TableCell>
</TableRow>
)} )}
</React.Fragment> </TableCell>
); </TableRow>
})} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@ -1000,15 +832,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 카드 형식 렌더링 (기존 방식) // 카드 형식 렌더링 (기존 방식)
const renderCardLayout = () => { const renderCardLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return ( return (
<> <>
{items.map((item, itemIndex) => { {items.map((item, itemIndex) => {
const isCollapsed = collapsible && collapsedItems.has(itemIndex); const isCollapsed = collapsible && collapsedItems.has(itemIndex);
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
return ( return (
<Card <Card
@ -1080,21 +907,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</div> </div>
))} ))}
</div> </div>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<div className="mt-3 border-t pt-3">
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</div>
)}
</CardContent> </CardContent>
)} )}

View File

@ -9,17 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator, Database, ArrowUp, ArrowDown } from "lucide-react";
import { import {
RepeaterFieldGroupConfig, RepeaterFieldGroupConfig,
RepeaterFieldDefinition, RepeaterFieldDefinition,
RepeaterFieldType, RepeaterFieldType,
CalculationOperator, CalculationOperator,
CalculationFormula, CalculationFormula,
SubDataLookupConfig,
} from "@/types/repeater"; } from "@/types/repeater";
import { apiClient } from "@/lib/api/client";
import { ColumnInfo } from "@/types/screen"; import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -96,56 +93,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
handleFieldsChange(localFields.filter((_, i) => i !== index)); handleFieldsChange(localFields.filter((_, i) => i !== index));
}; };
// 필드 순서 변경 (위로)
const moveFieldUp = (index: number) => {
if (index <= 0) return;
const newFields = [...localFields];
[newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
handleFieldsChange(newFields);
};
// 필드 순서 변경 (아래로)
const moveFieldDown = (index: number) => {
if (index >= localFields.length - 1) return;
const newFields = [...localFields];
[newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
handleFieldsChange(newFields);
};
// 드래그 앤 드롭 상태
const [draggedFieldIndex, setDraggedFieldIndex] = useState<number | null>(null);
// 필드 드래그 시작
const handleFieldDragStart = (index: number) => {
setDraggedFieldIndex(index);
};
// 필드 드래그 오버
const handleFieldDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
};
// 필드 드롭
const handleFieldDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedFieldIndex === null || draggedFieldIndex === targetIndex) {
setDraggedFieldIndex(null);
return;
}
const newFields = [...localFields];
const draggedField = newFields[draggedFieldIndex];
newFields.splice(draggedFieldIndex, 1);
newFields.splice(targetIndex, 0, draggedField);
handleFieldsChange(newFields);
setDraggedFieldIndex(null);
};
// 필드 드래그 종료
const handleFieldDragEnd = () => {
setDraggedFieldIndex(null);
};
// 필드 수정 (입력 중 - 로컬 상태만) // 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => { const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
setLocalInputs((prev) => ({ setLocalInputs((prev) => ({
@ -182,46 +129,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
const [tableSelectOpen, setTableSelectOpen] = useState(false); const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState(""); const [tableSearchValue, setTableSearchValue] = useState("");
// 하위 데이터 조회 설정 상태
const [subDataTableSelectOpen, setSubDataTableSelectOpen] = useState(false);
const [subDataTableSearchValue, setSubDataTableSearchValue] = useState("");
const [subDataTableColumns, setSubDataTableColumns] = useState<ColumnInfo[]>([]);
const [subDataLinkColumnOpen, setSubDataLinkColumnOpen] = useState(false);
const [subDataLinkColumnSearch, setSubDataLinkColumnSearch] = useState("");
// 하위 데이터 조회 테이블 컬럼 로드
const loadSubDataTableColumns = async (tableName: string) => {
if (!tableName) {
setSubDataTableColumns([]);
return;
}
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
let columns: ColumnInfo[] = [];
if (response.data?.success && response.data?.data) {
if (Array.isArray(response.data.data.columns)) {
columns = response.data.data.columns;
} else if (Array.isArray(response.data.data)) {
columns = response.data.data;
}
} else if (Array.isArray(response.data)) {
columns = response.data;
}
setSubDataTableColumns(columns);
console.log("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드:", { tableName, count: columns.length });
} catch (error) {
console.error("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드 실패:", error);
setSubDataTableColumns([]);
}
};
// 하위 데이터 테이블이 설정되어 있으면 컬럼 로드
useEffect(() => {
if (config.subDataLookup?.lookup?.tableName) {
loadSubDataTableColumns(config.subDataLookup.lookup.tableName);
}
}, [config.subDataLookup?.lookup?.tableName]);
// 필터링된 테이블 목록 // 필터링된 테이블 목록
const filteredTables = useMemo(() => { const filteredTables = useMemo(() => {
if (!tableSearchValue) return allTables; if (!tableSearchValue) return allTables;
@ -239,86 +146,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
return table ? table.displayName || table.tableName : config.targetTable; return table ? table.displayName || table.tableName : config.targetTable;
}, [config.targetTable, allTables]); }, [config.targetTable, allTables]);
// 하위 데이터 조회 테이블 표시명
const selectedSubDataTableLabel = useMemo(() => {
const tableName = config.subDataLookup?.lookup?.tableName;
if (!tableName) return "테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === tableName);
return table ? `${table.displayName || table.tableName} (${tableName})` : tableName;
}, [config.subDataLookup?.lookup?.tableName, allTables]);
// 필터링된 하위 데이터 테이블 컬럼
const filteredSubDataColumns = useMemo(() => {
if (!subDataLinkColumnSearch) return subDataTableColumns;
const searchLower = subDataLinkColumnSearch.toLowerCase();
return subDataTableColumns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchLower) ||
(col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)),
);
}, [subDataTableColumns, subDataLinkColumnSearch]);
// 하위 데이터 조회 설정 변경 핸들러
const handleSubDataLookupChange = (path: string, value: any) => {
const currentConfig = config.subDataLookup || {
enabled: false,
lookup: { tableName: "", linkColumn: "", displayColumns: [] },
selection: { mode: "single", requiredFields: [], requiredMode: "all" },
conditionalInput: { targetField: "" },
ui: { expandMode: "inline", maxHeight: "150px", showSummary: true },
};
// 경로를 따라 중첩 객체 업데이트
const pathParts = path.split(".");
let target: any = { ...currentConfig };
const newConfig = target;
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
target[part] = { ...target[part] };
target = target[part];
}
target[pathParts[pathParts.length - 1]] = value;
onChange({
...config,
subDataLookup: newConfig as SubDataLookupConfig,
});
};
// 표시 컬럼 토글 핸들러
const handleDisplayColumnToggle = (columnName: string, checked: boolean) => {
const currentColumns = config.subDataLookup?.lookup?.displayColumns || [];
let newColumns: string[];
if (checked) {
newColumns = [...currentColumns, columnName];
} else {
newColumns = currentColumns.filter((c) => c !== columnName);
}
handleSubDataLookupChange("lookup.displayColumns", newColumns);
};
// 필수 선택 필드 토글 핸들러
const handleRequiredFieldToggle = (fieldName: string, checked: boolean) => {
const currentFields = config.subDataLookup?.selection?.requiredFields || [];
let newFields: string[];
if (checked) {
newFields = [...currentFields, fieldName];
} else {
newFields = currentFields.filter((f) => f !== fieldName);
}
handleSubDataLookupChange("selection.requiredFields", newFields);
};
// 컬럼 라벨 업데이트 핸들러
const handleColumnLabelChange = (columnName: string, label: string) => {
const currentLabels = config.subDataLookup?.lookup?.columnLabels || {};
handleSubDataLookupChange("lookup.columnLabels", {
...currentLabels,
[columnName]: label,
});
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 대상 테이블 선택 */} {/* 대상 테이블 선택 */}
@ -423,485 +250,24 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p> </p>
</div> </div>
{/* 하위 데이터 조회 설정 */}
<div className="space-y-3 rounded-lg border-2 border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-purple-600" />
<Label className="text-sm font-semibold text-purple-800"> </Label>
</div>
<Switch
checked={config.subDataLookup?.enabled ?? false}
onCheckedChange={(checked) => handleSubDataLookupChange("enabled", checked)}
/>
</div>
<p className="text-xs text-purple-600">
/ .
</p>
{config.subDataLookup?.enabled && (
<div className="space-y-4 pt-2">
{/* 조회 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataTableSelectOpen} onOpenChange={setSubDataTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataTableSelectOpen}
className="h-9 w-full justify-between text-xs"
>
{selectedSubDataTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="테이블 검색..."
value={subDataTableSearchValue}
onValueChange={setSubDataTableSearchValue}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{allTables
.filter((table) => {
if (!subDataTableSearchValue) return true;
const searchLower = subDataTableSearchValue.toLowerCase();
return (
table.tableName.toLowerCase().includes(searchLower) ||
(table.displayName && table.displayName.toLowerCase().includes(searchLower))
);
})
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.tableName", currentValue);
loadSubDataTableColumns(currentValue);
setSubDataTableSelectOpen(false);
setSubDataTableSearchValue("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.tableName === table.tableName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{table.displayName || table.tableName}</div>
<div className="text-gray-500">{table.tableName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500">: inventory (), price_list ()</p>
</div>
{/* 연결 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataLinkColumnOpen} onOpenChange={setSubDataLinkColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataLinkColumnOpen}
className="h-9 w-full justify-between text-xs"
>
{config.subDataLookup?.lookup?.linkColumn
? (() => {
const col = subDataTableColumns.find(
(c) => c.columnName === config.subDataLookup?.lookup?.linkColumn,
);
return col
? `${col.columnLabel || col.columnName} (${col.columnName})`
: config.subDataLookup?.lookup?.linkColumn;
})()
: "연결 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={subDataLinkColumnSearch}
onValueChange={setSubDataLinkColumnSearch}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{filteredSubDataColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.linkColumn", currentValue);
setSubDataLinkColumnOpen(false);
setSubDataLinkColumnSearch("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.linkColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{col.columnLabel || col.columnName}</div>
<div className="text-gray-500">{col.columnName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500"> (: item_code)</p>
</div>
)}
{/* 표시 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && subDataTableColumns.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-white p-2">
{subDataTableColumns.map((col) => {
const isSelected = config.subDataLookup?.lookup?.displayColumns?.includes(col.columnName);
return (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`display-col-${col.columnName}`}
checked={isSelected}
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
/>
<Label
htmlFor={`display-col-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{col.columnLabel || col.columnName}
<span className="ml-1 text-gray-400">({col.columnName})</span>
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> (: 창고, , )</p>
</div>
)}
{/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.mode || "single"}
onValueChange={(v) => handleSubDataLookupChange("selection.mode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single" className="text-xs">
</SelectItem>
<SelectItem value="multiple" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 필수 선택 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<div className="flex flex-wrap gap-2">
{config.subDataLookup?.lookup?.displayColumns?.map((colName) => {
const col = subDataTableColumns.find((c) => c.columnName === colName);
const isRequired = config.subDataLookup?.selection?.requiredFields?.includes(colName);
return (
<div key={colName} className="flex items-center gap-1">
<Checkbox
id={`required-field-${colName}`}
checked={isRequired}
onCheckedChange={(checked) => handleRequiredFieldToggle(colName, checked as boolean)}
/>
<Label htmlFor={`required-field-${colName}`} className="cursor-pointer text-xs font-normal">
{col?.columnLabel || colName}
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> </p>
</div>
{/* 필수 조건 */}
{(config.subDataLookup?.selection?.requiredFields?.length || 0) > 1 && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.requiredMode || "all"}
onValueChange={(v) => handleSubDataLookupChange("selection.requiredMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">
</SelectItem>
<SelectItem value="any" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 조건부 입력 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 활성화 대상 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.conditionalInput?.targetField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.targetField", v === "__none__" ? "" : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{localFields.length === 0 ? (
<SelectItem value="__empty__" disabled className="text-xs text-gray-400">
</SelectItem>
) : (
localFields.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name} ({f.name})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500">
(: 출고수량)
{localFields.length === 0 && (
<span className="ml-1 text-amber-600">* </span>
)}
</p>
</div>
{/* 최대값 참조 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> ()</Label>
<Select
value={config.subDataLookup?.conditionalInput?.maxValueField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.maxValueField", v === "__none__" ? undefined : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{subDataTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500"> (: 재고수량)</p>
</div>
{/* 경고 임계값 */}
{config.subDataLookup?.conditionalInput?.maxValueField && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> (%)</Label>
<Input
type="number"
min={0}
max={100}
value={config.subDataLookup?.conditionalInput?.warningThreshold ?? 90}
onChange={(e) =>
handleSubDataLookupChange("conditionalInput.warningThreshold", parseInt(e.target.value) || 90)
}
className="h-8 text-xs"
/>
<p className="text-[10px] text-purple-500"> (: 90%)</p>
</div>
)}
</div>
)}
{/* UI 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700">UI </Label>
{/* 확장 방식 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.ui?.expandMode || "inline"}
onValueChange={(v) => handleSubDataLookupChange("ui.expandMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inline" className="text-xs">
( )
</SelectItem>
<SelectItem value="modal" className="text-xs">
()
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 최대 높이 */}
{config.subDataLookup?.ui?.expandMode === "inline" && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Input
value={config.subDataLookup?.ui?.maxHeight || "150px"}
onChange={(e) => handleSubDataLookupChange("ui.maxHeight", e.target.value)}
placeholder="150px"
className="h-8 text-xs"
/>
</div>
)}
{/* 요약 정보 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="sub-data-show-summary"
checked={config.subDataLookup?.ui?.showSummary ?? true}
onCheckedChange={(checked) => handleSubDataLookupChange("ui.showSummary", checked)}
/>
<Label htmlFor="sub-data-show-summary" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
)}
{/* 설정 요약 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="rounded bg-purple-100 p-2 text-xs">
<p className="font-medium text-purple-800"> </p>
<ul className="mt-1 space-y-0.5 text-purple-700">
<li> : {config.subDataLookup?.lookup?.tableName || "-"}</li>
<li> : {config.subDataLookup?.lookup?.linkColumn || "-"}</li>
<li> : {config.subDataLookup?.lookup?.displayColumns?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.selection?.requiredFields?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.conditionalInput?.targetField || "-"}</li>
</ul>
</div>
)}
</div>
)}
</div>
{/* 필드 정의 */} {/* 필드 정의 */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <Label className="text-sm font-semibold"> </Label>
<Label className="text-sm font-semibold"> </Label>
<span className="text-xs text-gray-500"> </span>
</div>
{localFields.map((field, index) => ( {localFields.map((field, index) => (
<Card <Card key={`${field.name}-${index}`} className="border-2">
key={`${field.name}-${index}`}
className={cn(
"border-2 transition-all",
draggedFieldIndex === index && "opacity-50 border-blue-400",
draggedFieldIndex !== null && draggedFieldIndex !== index && "border-dashed",
)}
draggable
onDragStart={() => handleFieldDragStart(index)}
onDragOver={(e) => handleFieldDragOver(e, index)}
onDrop={(e) => handleFieldDrop(e, index)}
onDragEnd={handleFieldDragEnd}
>
<CardContent className="space-y-3 pt-4"> <CardContent className="space-y-3 pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
{/* 드래그 핸들 */} <Button
<GripVertical className="h-4 w-4 cursor-move text-gray-400 hover:text-gray-600" /> type="button"
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span> variant="ghost"
</div> size="icon"
<div className="flex items-center gap-1"> onClick={() => removeField(index)}
{/* 순서 변경 버튼 */} className="h-6 w-6 text-red-500 hover:bg-red-50"
<Button >
type="button" <X className="h-3 w-3" />
variant="ghost" </Button>
size="icon"
onClick={() => moveFieldUp(index)}
disabled={index === 0}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="위로 이동"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => moveFieldDown(index)}
disabled={index === localFields.length - 1}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="아래로 이동"
>
<ArrowDown className="h-3 w-3" />
</Button>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
title="삭제"
>
<X className="h-3 w-3" />
</Button>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">

View File

@ -141,4 +141,3 @@ export const useActiveTabOptional = () => {

View File

@ -13,7 +13,6 @@ import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
interface ScreenContextValue { interface ScreenContextValue {
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장) // 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
@ -40,7 +39,6 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps { interface ScreenContextProviderProps {
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
menuObjid?: number; // 메뉴 OBJID
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
children: React.ReactNode; children: React.ReactNode;
} }
@ -51,7 +49,6 @@ interface ScreenContextProviderProps {
export function ScreenContextProvider({ export function ScreenContextProvider({
screenId, screenId,
tableName, tableName,
menuObjid,
splitPanelPosition, splitPanelPosition,
children, children,
}: ScreenContextProviderProps) { }: ScreenContextProviderProps) {
@ -115,7 +112,6 @@ export function ScreenContextProvider({
() => ({ () => ({
screenId, screenId,
tableName, tableName,
menuObjid,
splitPanelPosition, splitPanelPosition,
formData, formData,
updateFormData, updateFormData,
@ -131,7 +127,6 @@ export function ScreenContextProvider({
[ [
screenId, screenId,
tableName, tableName,
menuObjid,
splitPanelPosition, splitPanelPosition,
formData, formData,
updateFormData, updateFormData,

View File

@ -1,182 +0,0 @@
"use client";
import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from "react";
import { apiClient } from "@/lib/api/client";
import { useMultiLang } from "@/hooks/useMultiLang";
import { ComponentData } from "@/types/screen";
interface ScreenMultiLangContextValue {
translations: Record<string, string>;
loading: boolean;
getTranslatedText: (langKey: string | undefined, fallback: string) => string;
}
const ScreenMultiLangContext = createContext<ScreenMultiLangContextValue | null>(null);
interface ScreenMultiLangProviderProps {
children: ReactNode;
components: ComponentData[];
companyCode?: string;
}
/**
* Provider
* langKey를 ,
*/
export const ScreenMultiLangProvider: React.FC<ScreenMultiLangProviderProps> = ({
children,
components,
companyCode = "*",
}) => {
const { userLang } = useMultiLang();
const [translations, setTranslations] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
// 모든 컴포넌트에서 langKey 수집
const langKeys = useMemo(() => {
const keys: string[] = [];
const collectLangKeys = (comps: ComponentData[]) => {
comps.forEach((comp) => {
// 컴포넌트 라벨의 langKey
if ((comp as any).langKey) {
keys.push((comp as any).langKey);
}
// componentConfig 내의 langKey (버튼 텍스트 등)
if ((comp as any).componentConfig?.langKey) {
keys.push((comp as any).componentConfig.langKey);
}
// properties 내의 langKey (레거시)
if ((comp as any).properties?.langKey) {
keys.push((comp as any).properties.langKey);
}
// 테이블 리스트 컬럼의 langKey 수집
if ((comp as any).componentConfig?.columns) {
(comp as any).componentConfig.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
// 분할패널 좌측/우측 제목 langKey 수집
const config = (comp as any).componentConfig;
if (config?.leftPanel?.langKey) {
keys.push(config.leftPanel.langKey);
}
if (config?.rightPanel?.langKey) {
keys.push(config.rightPanel.langKey);
}
// 분할패널 좌측/우측 컬럼 langKey 수집
if (config?.leftPanel?.columns) {
config.leftPanel.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
if (config?.rightPanel?.columns) {
config.rightPanel.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
// 추가 탭 langKey 수집
if (config?.additionalTabs) {
config.additionalTabs.forEach((tab: any) => {
if (tab.langKey) {
keys.push(tab.langKey);
}
if (tab.titleLangKey) {
keys.push(tab.titleLangKey);
}
if (tab.columns) {
tab.columns.forEach((col: any) => {
if (col.langKey) {
keys.push(col.langKey);
}
});
}
});
}
// 자식 컴포넌트 재귀 처리
if ((comp as any).children) {
collectLangKeys((comp as any).children);
}
});
};
collectLangKeys(components);
return [...new Set(keys)]; // 중복 제거
}, [components]);
// langKey가 있으면 배치 조회
useEffect(() => {
const loadTranslations = async () => {
if (langKeys.length === 0 || !userLang) {
return;
}
setLoading(true);
try {
console.log("🌐 [ScreenMultiLang] 다국어 배치 로드:", { langKeys: langKeys.length, userLang, companyCode });
const response = await apiClient.post(
"/multilang/batch",
{ langKeys },
{
params: {
userLang,
companyCode,
},
}
);
if (response.data?.success && response.data?.data) {
console.log("✅ [ScreenMultiLang] 다국어 로드 완료:", Object.keys(response.data.data).length, "개");
setTranslations(response.data.data);
}
} catch (error) {
console.error("❌ [ScreenMultiLang] 다국어 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadTranslations();
}, [langKeys, userLang, companyCode]);
// 번역 텍스트 가져오기 헬퍼
const getTranslatedText = (langKey: string | undefined, fallback: string): string => {
if (!langKey) return fallback;
return translations[langKey] || fallback;
};
const value = useMemo(
() => ({
translations,
loading,
getTranslatedText,
}),
[translations, loading]
);
return <ScreenMultiLangContext.Provider value={value}>{children}</ScreenMultiLangContext.Provider>;
};
/**
*
*/
export const useScreenMultiLang = (): ScreenMultiLangContextValue => {
const context = useContext(ScreenMultiLangContext);
if (!context) {
// 컨텍스트가 없으면 기본값 반환 (fallback)
return {
translations: {},
loading: false,
getTranslatedText: (_, fallback) => fallback,
};
}
return context;
};

View File

@ -198,4 +198,3 @@ export function applyAutoFillToFormData(

View File

@ -202,19 +202,14 @@ export class DynamicFormApi {
* *
* @param id ID * @param id ID
* @param tableName * @param tableName
* @param screenId ID ( , )
* @returns * @returns
*/ */
static async deleteFormDataFromTable( static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
id: string | number,
tableName: string,
screenId?: number
): Promise<ApiResponse<void>> {
try { try {
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId }); console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
await apiClient.delete(`/dynamic-form/${id}`, { await apiClient.delete(`/dynamic-form/${id}`, {
data: { tableName, screenId }, data: { tableName },
}); });
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공"); console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
@ -561,192 +556,6 @@ export class DynamicFormApi {
}; };
} }
} }
// ================================
// 마스터-디테일 엑셀 API
// ================================
/**
* -
* @param screenId ID
* @returns - (null이면 - )
*/
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
try {
console.log("🔍 마스터-디테일 관계 조회:", screenId);
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
return {
success: true,
data: response.data?.data || null,
message: response.data?.message || "조회 완료",
};
} catch (error: any) {
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
return {
success: false,
data: null,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* @param screenId ID
* @param filters
* @returns JOIN된
*/
static async getMasterDetailDownloadData(
screenId: number,
filters?: Record<string, any>
): Promise<ApiResponse<MasterDetailDownloadData>> {
try {
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
const response = await apiClient.post(`/data/master-detail/download`, {
screenId,
filters,
});
return {
success: true,
data: response.data?.data,
message: "데이터 조회 완료",
};
} catch (error: any) {
console.error("❌ 마스터-디테일 다운로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* @param screenId ID
* @param data
* @returns
*/
static async uploadMasterDetailData(
screenId: number,
data: Record<string, any>[]
): Promise<ApiResponse<MasterDetailUploadResult>> {
try {
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
const response = await apiClient.post(`/data/master-detail/upload`, {
screenId,
data,
});
return {
success: response.data?.success,
data: response.data?.data,
message: response.data?.message,
};
} catch (error: any) {
console.error("❌ 마스터-디테일 업로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
* -
* - UI에서
* -
* -
* @param screenId ID
* @param detailData
* @param masterFieldValues UI에서
* @param numberingRuleId ID (optional)
* @param afterUploadFlowId ID (optional, )
* @param afterUploadFlows (optional)
* @returns
*/
static async uploadMasterDetailSimple(
screenId: number,
detailData: Record<string, any>[],
masterFieldValues: Record<string, any>,
numberingRuleId?: string,
afterUploadFlowId?: string,
afterUploadFlows?: Array<{ flowId: string; order: number }>
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
try {
console.log("📤 마스터-디테일 간단 모드 업로드:", {
screenId,
detailRowCount: detailData.length,
masterFieldValues,
numberingRuleId,
afterUploadFlows: afterUploadFlows?.length || 0,
});
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
screenId,
detailData,
masterFieldValues,
numberingRuleId,
afterUploadFlowId,
afterUploadFlows,
});
return {
success: response.data?.success,
data: response.data?.data,
message: response.data?.message,
};
} catch (error: any) {
console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
}
// 마스터-디테일 관계 타입
export interface MasterDetailRelation {
masterTable: string;
detailTable: string;
masterKeyColumn: string;
detailFkColumn: string;
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
}
// 마스터-디테일 다운로드 데이터 타입
export interface MasterDetailDownloadData {
headers: string[];
columns: string[];
data: Record<string, any>[];
masterColumns: string[];
detailColumns: string[];
joinKey: string;
}
// 마스터-디테일 업로드 결과 타입
export interface MasterDetailUploadResult {
success: boolean;
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailDeleted: number;
errors: string[];
}
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
export interface MasterDetailSimpleUploadResult {
success: boolean;
masterInserted: number;
detailInserted: number;
generatedKey: string; // 생성된 마스터 키
errors?: string[];
} }
// 편의를 위한 기본 export // 편의를 위한 기본 export

View File

@ -77,26 +77,21 @@ export const entityJoinApi = {
filterColumn?: string; filterColumn?: string;
filterValue?: any; filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외) }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
companyCodeOverride?: string; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
} = {}, } = {},
): Promise<EntityJoinResponse> => { ): Promise<EntityJoinResponse> => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화 // 🔒 멀티테넌시: company_code 자동 필터링 활성화
const autoFilter: { const autoFilter = {
enabled: boolean;
filterColumn: string;
userField: string;
companyCodeOverride?: string;
} = {
enabled: true, enabled: true,
filterColumn: "company_code", filterColumn: "company_code",
userField: "companyCode", userField: "companyCode",
}; };
// 🆕 프리뷰 모드에서 회사 코드 오버라이드 (최고 관리자만 백엔드에서 허용)
if (params.companyCodeOverride) {
autoFilter.companyCodeOverride = params.companyCodeOverride;
}
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
params: { params: {
page: params.page, page: params.page,
@ -107,9 +102,10 @@ export const entityJoinApi = {
search: params.search ? JSON.stringify(params.search) : undefined, search: params.search ? JSON.stringify(params.search) : undefined,
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 (오버라이드 포함) autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터 dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터 excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
}, },
}); });
return response.data.data; return response.data.data;

View File

@ -1,402 +0,0 @@
/**
* API
* , ,
*/
import { apiClient } from "./client";
// =====================================================
// 타입 정의
// =====================================================
export interface Language {
langCode: string;
langName: string;
langNative: string;
isActive: string;
sortOrder?: number;
}
export interface LangCategory {
categoryId: number;
categoryCode: string;
categoryName: string;
parentId?: number | null;
level: number;
keyPrefix: string;
description?: string;
sortOrder: number;
isActive: string;
children?: LangCategory[];
}
export interface LangKey {
keyId?: number;
companyCode: string;
menuName?: string;
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
}
export interface LangText {
textId?: number;
keyId: number;
langCode: string;
langText: string;
isActive: string;
}
export interface GenerateKeyRequest {
companyCode: string;
categoryId: number;
keyMeaning: string;
usageNote?: string;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface CreateOverrideKeyRequest {
companyCode: string;
baseKeyId: number;
texts: Array<{
langCode: string;
langText: string;
}>;
}
export interface KeyPreview {
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}
export interface ApiResponse<T> {
success: boolean;
message?: string;
data?: T;
error?: {
code: string;
details?: any;
};
}
// =====================================================
// 카테고리 관련 API
// =====================================================
/**
*
*/
export async function getCategories(): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get("/multilang/categories");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function getCategoryById(categoryId: number): Promise<ApiResponse<LangCategory>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
* ( )
*/
export async function getCategoryPath(categoryId: number): Promise<ApiResponse<LangCategory[]>> {
try {
const response = await apiClient.get(`/multilang/categories/${categoryId}/path`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "CATEGORY_PATH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 언어 관련 API
// =====================================================
/**
*
*/
export async function getLanguages(): Promise<ApiResponse<Language[]>> {
try {
const response = await apiClient.get("/multilang/languages");
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "LANGUAGE_FETCH_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 키 관련 API
// =====================================================
/**
*
*/
export async function getLangKeys(params?: {
companyCode?: string;
menuCode?: string;
categoryId?: number;
searchText?: string;
}): Promise<ApiResponse<LangKey[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.companyCode) queryParams.append("companyCode", params.companyCode);
if (params?.menuCode) queryParams.append("menuCode", params.menuCode);
if (params?.categoryId) queryParams.append("categoryId", params.categoryId.toString());
if (params?.searchText) queryParams.append("searchText", params.searchText);
const url = `/multilang/keys${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
const response = await apiClient.get(url);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function getLangTexts(keyId: number): Promise<ApiResponse<LangText[]>> {
try {
const response = await apiClient.get(`/multilang/keys/${keyId}/texts`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function generateKey(data: GenerateKeyRequest): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/generate", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_GENERATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
*
*/
export async function previewKey(
categoryId: number,
keyMeaning: string,
companyCode: string
): Promise<ApiResponse<KeyPreview>> {
try {
const response = await apiClient.post("/multilang/keys/preview", {
categoryId,
keyMeaning,
companyCode,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_PREVIEW_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function createOverrideKey(
data: CreateOverrideKeyRequest
): Promise<ApiResponse<number>> {
try {
const response = await apiClient.post("/multilang/keys/override", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_CREATE_ERROR",
details: error.response?.data?.error?.details || error.message,
},
};
}
}
/**
*
*/
export async function getOverrideKeys(companyCode: string): Promise<ApiResponse<LangKey[]>> {
try {
const response = await apiClient.get(`/multilang/keys/overrides/${companyCode}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "OVERRIDE_KEYS_FETCH_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function saveLangTexts(
keyId: number,
texts: Array<{ langCode: string; langText: string }>
): Promise<ApiResponse<string>> {
try {
const response = await apiClient.post(`/multilang/keys/${keyId}/texts`, { texts });
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "TEXTS_SAVE_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function deleteLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.delete(`/multilang/keys/${keyId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_DELETE_ERROR",
details: error.message,
},
};
}
}
/**
*
*/
export async function toggleLangKey(keyId: number): Promise<ApiResponse<string>> {
try {
const response = await apiClient.put(`/multilang/keys/${keyId}/toggle`);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "KEY_TOGGLE_ERROR",
details: error.message,
},
};
}
}
// =====================================================
// 화면 라벨 다국어 자동 생성 API
// =====================================================
export interface ScreenLabelKeyResult {
componentId: string;
keyId: number;
langKey: string;
}
export interface GenerateScreenLabelKeysRequest {
screenId: number;
menuObjId?: string;
labels: Array<{
componentId: string;
label: string;
type?: string;
}>;
}
/**
*
*/
export async function generateScreenLabelKeys(
params: GenerateScreenLabelKeysRequest
): Promise<ApiResponse<ScreenLabelKeyResult[]>> {
try {
const response = await apiClient.post("/multilang/screen-labels", params);
return response.data;
} catch (error: any) {
return {
success: false,
error: {
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
details: error.message,
},
};
}
}

View File

@ -105,18 +105,6 @@ 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}`, {

View File

@ -1,594 +0,0 @@
/**
* API
* - (screen_groups)
* - - (screen_group_screens)
* - (screen_field_joins)
* - (screen_data_flows)
* - - (screen_table_relations)
*/
import { apiClient } from "./client";
// ============================================================
// 타입 정의
// ============================================================
export interface ScreenGroup {
id: number;
group_name: string;
group_code: string;
main_table_name?: string;
description?: string;
icon?: string;
display_order: number;
is_active: string;
company_code: string;
created_date?: string;
updated_date?: string;
writer?: string;
screen_count?: number;
screens?: ScreenGroupScreen[];
parent_group_id?: number | null; // 상위 그룹 ID
group_level?: number; // 그룹 레벨 (0: 대분류, 1: 중분류, 2: 소분류 ...)
hierarchy_path?: string; // 계층 경로
}
export interface ScreenGroupScreen {
id: number;
group_id: number;
screen_id: number;
screen_name?: string;
screen_role: string;
display_order: number;
is_default: string;
company_code: string;
}
export interface FieldJoin {
id: number;
screen_id: number;
layout_id?: number;
component_id?: string;
field_name?: string;
save_table: string;
save_column: string;
join_table: string;
join_column: string;
display_column: string;
join_type: string;
filter_condition?: string;
sort_column?: string;
sort_direction?: string;
is_active: string;
save_table_label?: string;
join_table_label?: string;
}
export interface DataFlow {
id: number;
group_id?: number;
source_screen_id: number;
source_action?: string;
target_screen_id: number;
target_action?: string;
data_mapping?: Record<string, any>;
flow_type: string;
flow_label?: string;
condition_expression?: string;
is_active: string;
source_screen_name?: string;
target_screen_name?: string;
group_name?: string;
}
export interface TableRelation {
id: number;
group_id?: number;
screen_id: number;
table_name: string;
relation_type: string;
crud_operations: string;
description?: string;
is_active: string;
screen_name?: string;
group_name?: string;
table_label?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
total?: number;
page?: number;
size?: number;
totalPages?: number;
}
// ============================================================
// 화면 그룹 (screen_groups) API
// ============================================================
export async function getScreenGroups(params?: {
page?: number;
size?: number;
searchTerm?: string;
}): Promise<ApiResponse<ScreenGroup[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.size) queryParams.append("size", params.size.toString());
if (params?.searchTerm) queryParams.append("searchTerm", params.searchTerm);
const response = await apiClient.get(`/screen-groups/groups?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function getScreenGroup(id: number): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.get(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createScreenGroup(data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.post("/screen-groups/groups", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenGroup(id: number, data: Partial<ScreenGroup>): Promise<ApiResponse<ScreenGroup>> {
try {
const response = await apiClient.put(`/screen-groups/groups/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteScreenGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/groups/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-그룹 연결 (screen_group_screens) API
// ============================================================
export async function addScreenToGroup(data: {
group_id: number;
screen_id: number;
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.post("/screen-groups/group-screens", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateScreenInGroup(id: number, data: {
screen_role?: string;
display_order?: number;
is_default?: string;
}): Promise<ApiResponse<ScreenGroupScreen>> {
try {
const response = await apiClient.put(`/screen-groups/group-screens/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function removeScreenFromGroup(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/group-screens/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 필드 조인 (screen_field_joins) API
// ============================================================
export async function getFieldJoins(screenId?: number): Promise<ApiResponse<FieldJoin[]>> {
try {
const queryParams = screenId ? `?screen_id=${screenId}` : "";
const response = await apiClient.get(`/screen-groups/field-joins${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createFieldJoin(data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.post("/screen-groups/field-joins", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateFieldJoin(id: number, data: Partial<FieldJoin>): Promise<ApiResponse<FieldJoin>> {
try {
const response = await apiClient.put(`/screen-groups/field-joins/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteFieldJoin(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/field-joins/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 데이터 흐름 (screen_data_flows) API
// ============================================================
export async function getDataFlows(params?: { groupId?: number; sourceScreenId?: number }): Promise<ApiResponse<DataFlow[]>> {
try {
const queryParts: string[] = [];
if (params?.groupId) {
queryParts.push(`group_id=${params.groupId}`);
}
if (params?.sourceScreenId) {
queryParts.push(`source_screen_id=${params.sourceScreenId}`);
}
const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
const response = await apiClient.get(`/screen-groups/data-flows${queryString}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createDataFlow(data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.post("/screen-groups/data-flows", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateDataFlow(id: number, data: Partial<DataFlow>): Promise<ApiResponse<DataFlow>> {
try {
const response = await apiClient.put(`/screen-groups/data-flows/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteDataFlow(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/data-flows/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면-테이블 관계 (screen_table_relations) API
// ============================================================
export async function getTableRelations(params?: {
screen_id?: number;
group_id?: number;
}): Promise<ApiResponse<TableRelation[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.screen_id) queryParams.append("screen_id", params.screen_id.toString());
if (params?.group_id) queryParams.append("group_id", params.group_id.toString());
const response = await apiClient.get(`/screen-groups/table-relations?${queryParams.toString()}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function createTableRelation(data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.post("/screen-groups/table-relations", data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updateTableRelation(id: number, data: Partial<TableRelation>): Promise<ApiResponse<TableRelation>> {
try {
const response = await apiClient.put(`/screen-groups/table-relations/${id}`, data);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deleteTableRelation(id: number): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-groups/table-relations/${id}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 화면 레이아웃 요약 (미리보기용) API
// ============================================================
// 레이아웃 아이템 (미니어처 렌더링용)
export interface LayoutItem {
x: number;
y: number;
width: number;
height: number;
componentKind: string; // 정확한 컴포넌트 종류 (table-list, button-primary 등)
widgetType: string; // 일반적인 위젯 타입 (button, text 등)
label?: string;
bindField?: string; // 바인딩된 필드명 (컬럼명)
usedColumns?: string[]; // 이 컴포넌트에서 사용하는 컬럼 목록
joinColumns?: string[]; // 이 컴포넌트에서 조인 컬럼 목록 (isEntityJoin=true)
}
export interface ScreenLayoutSummary {
screenId: number;
screenType: 'form' | 'grid' | 'dashboard' | 'action';
widgetCounts: Record<string, number>;
totalComponents: number;
// 미니어처 렌더링용 레이아웃 데이터
layoutItems: LayoutItem[];
canvasWidth: number;
canvasHeight: number;
}
// 단일 화면 레이아웃 요약 조회
export async function getScreenLayoutSummary(screenId: number): Promise<ApiResponse<ScreenLayoutSummary>> {
try {
const response = await apiClient.get(`/screen-groups/layout-summary/${screenId}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 여러 화면 레이아웃 요약 일괄 조회
export async function getMultipleScreenLayoutSummary(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenLayoutSummary>>> {
try {
const response = await apiClient.post("/screen-groups/layout-summary/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 필드 매핑 정보 타입
export interface FieldMappingInfo {
sourceTable?: string; // 연관 테이블명 (parentDataMapping에서 사용)
sourceField: string;
targetField: string;
sourceDisplayName?: string; // 메인 테이블 한글 컬럼명
targetDisplayName?: string; // 서브 테이블 한글 컬럼명
}
// 서브 테이블 정보 타입
export interface SubTableInfo {
tableName: string;
tableLabel?: string; // 테이블 한글명
componentType: string;
relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation';
fieldMappings?: FieldMappingInfo[];
filterColumns?: string[]; // 필터링에 사용되는 컬럼 목록
// rightPanelRelation에서 추가 정보 (관계 유형 추론용)
originalRelationType?: 'join' | 'detail'; // 원본 relation.type
foreignKey?: string; // 디테일 테이블의 FK 컬럼
leftColumn?: string; // 마스터 테이블의 선택 기준 컬럼
// rightPanel.columns에서 외부 테이블 참조 정보
joinedTables?: string[]; // 참조하는 외부 테이블들 (예: ['customer_mng'])
joinColumns?: string[]; // 외부 테이블과 조인하는 FK 컬럼들 (예: ['customer_id'])
joinColumnRefs?: Array<{ // FK 컬럼 참조 정보 (어떤 테이블.컬럼에서 오는지)
column: string; // FK 컬럼명 (예: 'customer_id')
columnLabel: string; // FK 컬럼 한글명 (예: '거래처 ID')
refTable: string; // 참조 테이블 (예: 'customer_mng')
refTableLabel: string; // 참조 테이블 한글명 (예: '거래처 관리')
refColumn: string; // 참조 컬럼 (예: 'customer_code')
}>;
}
// 시각적 관계 유형 (시각화에서 사용)
export type VisualRelationType = 'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join';
// 관계 유형 추론 함수
export function inferVisualRelationType(subTable: SubTableInfo): VisualRelationType {
// 1. split-panel-layout의 rightPanel.relation
if (subTable.relationType === 'rightPanelRelation') {
// 원본 relation.type 기반 구분
if (subTable.originalRelationType === 'detail') {
return 'hierarchy'; // 부모-자식 계층 구조 (같은 테이블 자기 참조)
}
return 'filter'; // 마스터-디테일 필터링
}
// 2. selected-items-detail-input의 parentDataMapping
// parentDataMapping은 FK 관계를 정의하므로 조인으로 분류
if (subTable.relationType === 'parentMapping') {
return 'join'; // FK 조인 (sourceTable.sourceField → targetTable.targetField)
}
// 3. column_labels.reference_table
if (subTable.relationType === 'reference') {
return 'join'; // 실제 엔티티 조인 (LEFT JOIN 등)
}
// 4. autocomplete, entity-search
if (subTable.relationType === 'lookup') {
return 'lookup'; // 코드→명칭 변환
}
// 5. 기타 (source, join 등)
return 'join';
}
// 저장 테이블 정보 타입
export interface SaveTableInfo {
tableName: string;
saveType: 'save' | 'edit' | 'delete' | 'transferData';
componentType: string;
isMainTable: boolean;
mappingRules?: Array<{
sourceField: string;
targetField: string;
transform?: string;
}>;
}
export interface ScreenSubTablesData {
screenId: number;
screenName: string;
mainTable: string;
subTables: SubTableInfo[];
saveTables?: SaveTableInfo[]; // 저장 대상 테이블 목록
}
// 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계)
export async function getScreenSubTables(
screenIds: number[]
): Promise<ApiResponse<Record<number, ScreenSubTablesData>>> {
try {
const response = await apiClient.post("/screen-groups/sub-tables/batch", { screenIds });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// ============================================================
// 메뉴-화면그룹 동기화 API
// ============================================================
export interface SyncDetail {
action: 'created' | 'linked' | 'skipped' | 'error';
sourceName: string;
sourceId: number | string;
targetId?: number | string;
reason?: string;
}
export interface SyncResult {
success: boolean;
created: number;
linked: number;
skipped: number;
errors: string[];
details: SyncDetail[];
}
export interface SyncStatus {
screenGroups: { total: number; linked: number; unlinked: number };
menuItems: { total: number; linked: number; unlinked: number };
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
}
// 동기화 상태 조회
export async function getMenuScreenSyncStatus(
targetCompanyCode?: string
): Promise<ApiResponse<SyncStatus>> {
try {
const queryParams = targetCompanyCode ? `?targetCompanyCode=${targetCompanyCode}` : '';
const response = await apiClient.get(`/screen-groups/sync/status${queryParams}`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 화면관리 → 메뉴 동기화
export async function syncScreenGroupsToMenu(
targetCompanyCode?: string
): Promise<ApiResponse<SyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/screen-to-menu", { targetCompanyCode });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 메뉴 → 화면관리 동기화
export async function syncMenuToScreenGroups(
targetCompanyCode?: string
): Promise<ApiResponse<SyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/menu-to-screen", { targetCompanyCode });
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}
// 전체 동기화 결과 타입
export interface AllCompaniesSyncResult {
totalCompanies: number;
successCount: number;
failedCount: number;
totalCreated: number;
totalLinked: number;
details: Array<{
companyCode: string;
companyName: string;
direction: 'screens-to-menus' | 'menus-to-screens';
created: number;
linked: number;
skipped: number;
success: boolean;
error?: string;
}>;
}
// 전체 회사 동기화 (최고 관리자만)
export async function syncAllCompanies(): Promise<ApiResponse<AllCompaniesSyncResult>> {
try {
const response = await apiClient.post("/screen-groups/sync/all");
return response.data;
} catch (error: any) {
return { success: false, error: error.message };
}
}

View File

@ -13,7 +13,7 @@ export interface ColumnTypeInfo {
dataType: string; dataType: string;
dbType: string; dbType: string;
webType: string; webType: string;
inputType?: string; // text, number, entity, code, select, date, checkbox 등 inputType?: "direct" | "auto";
detailSettings: string; detailSettings: string;
description?: string; description?: string;
isNullable: string; isNullable: string;
@ -39,11 +39,11 @@ export interface TableInfo {
columnCount: number; columnCount: number;
} }
// 컬럼 설정 타입 (백엔드 API와 동일한 필드명 사용) // 컬럼 설정 타입
export interface ColumnSettings { export interface ColumnSettings {
columnName?: string; columnName?: string;
columnLabel: string; columnLabel: string;
inputType: string; // 백엔드에서 inputType으로 받음 webType: string;
detailSettings: string; detailSettings: string;
codeCategory: string; codeCategory: string;
codeValue: string; codeValue: string;

View File

@ -281,12 +281,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || component.id;
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화 // modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue; let currentValue;
if (componentType === "modal-repeater-table" || if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
componentType === "repeat-screen-modal" || // EditModal에서 전달된 groupedData가 있으면 우선 사용
componentType === "selected-items-detail-input") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || []; currentValue = props.groupedData || formData?.[fieldName] || [];
} else { } else {
currentValue = formData?.[fieldName] || ""; currentValue = formData?.[fieldName] || "";

View File

@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨) // 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({}); const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
// label: component.label,
// hasData: !!newData,
// dataKeys: newData ? Object.keys(newData) : [],
// });
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트) // modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => { useEffect(() => {
const actionConfig = component.componentConfig?.action; const actionConfig = component.componentConfig?.action;
@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. 분할 패널 좌측 선택 데이터 확인 // 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") { if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인 // SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) { if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
if (!hasSelection) { if (!hasSelection) {
hasSelection = true; hasSelection = true;
selectionCount = 1; selectionCount = 1;
@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
selectionCount, selectionCount,
selectionSource, selectionSource,
hasSplitPanelContext: !!splitPanelContext, hasSplitPanelContext: !!splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData, trackedSelectedLeftData: trackedSelectedLeftData,
selectedRowsData: selectedRowsData?.length, selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length, selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length, flowSelectedData: flowSelectedData?.length,
@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component.label, component.label,
selectedRows, selectedRows,
selectedRowsData, selectedRowsData,
splitPanelContext?.selectedLeftData, trackedSelectedLeftData,
flowSelectedData, flowSelectedData,
splitPanelContext, splitPanelContext,
modalStoreData, modalStoreData,
@ -495,50 +509,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함 ...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
} as ButtonPrimaryConfig; } as ButtonPrimaryConfig;
// 🎨 동적 색상 설정 (webTypeConfig 우선, 레거시 style.labelColor 지원) // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const getButtonBackgroundColor = () => { const getLabelColor = () => {
// 1순위: webTypeConfig.backgroundColor (화면설정 모달에서 저장)
if (component.webTypeConfig?.backgroundColor) {
return component.webTypeConfig.backgroundColor;
}
// 2순위: componentConfig.backgroundColor
if (componentConfig.backgroundColor) {
return componentConfig.backgroundColor;
}
// 3순위: style.backgroundColor
if (component.style?.backgroundColor) {
return component.style.backgroundColor;
}
// 4순위: style.labelColor (레거시)
if (component.style?.labelColor) {
return component.style.labelColor;
}
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
if (isDeleteAction()) { if (isDeleteAction()) {
return "#ef4444"; // 빨간색 (Tailwind red-500) return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500)
} }
return "#3b82f6"; // 파란색 (Tailwind blue-500) return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary)
}; };
const getButtonTextColor = () => { const buttonColor = getLabelColor();
// 1순위: webTypeConfig.textColor (화면설정 모달에서 저장)
if (component.webTypeConfig?.textColor) {
return component.webTypeConfig.textColor;
}
// 2순위: componentConfig.textColor
if (componentConfig.textColor) {
return componentConfig.textColor;
}
// 3순위: style.color
if (component.style?.color) {
return component.style.color;
}
// 기본값: 흰색
return "#ffffff";
};
const buttonColor = getButtonBackgroundColor();
const buttonTextColor = getButtonTextColor();
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
const processedConfig = { ...componentConfig }; const processedConfig = { ...componentConfig };
@ -1150,7 +1129,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} : undefined, } : undefined,
} as ButtonActionContext; } as ButtonActionContext;
// 확인이 필요한 액션인지 확인 (save/delete만 확인 다이얼로그 표시) // 확인이 필요한 액션인지 확인
if (confirmationRequiredActions.includes(processedConfig.action.type)) { if (confirmationRequiredActions.includes(processedConfig.action.type)) {
// 확인 다이얼로그 표시 // 확인 다이얼로그 표시
setPendingAction({ setPendingAction({
@ -1286,8 +1265,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
minHeight: "40px", minHeight: "40px",
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원 color: finalDisabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg) // 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600", fontWeight: "600",

View File

@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
// 🆕 연관 데이터 버튼 컴포넌트 // 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시 import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
/** /**
* *
*/ */

View File

@ -180,11 +180,8 @@ export function ModalRepeaterTableComponent({
filterCondition: propFilterCondition, filterCondition: propFilterCondition,
companyCode: propCompanyCode, companyCode: propCompanyCode,
// 🆕 그룹 데이터 (EditModal에서 전달, 같은 그룹의 여러 품목)
groupedData,
...props ...props
}: ModalRepeaterTableComponentProps & { groupedData?: Record<string, any>[] }) { }: ModalRepeaterTableComponentProps) {
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합 // ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = { const componentConfig = {
...config, ...config,
@ -211,16 +208,9 @@ export function ModalRepeaterTableComponent({
// 모달 필터 설정 // 모달 필터 설정
const modalFilters = componentConfig?.modalFilters || []; const modalFilters = componentConfig?.modalFilters || [];
// ✅ value는 groupedData 우선, 없으면 formData[columnName], 없으면 prop 사용 // ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName; const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 groupedData가 전달되면 (EditModal에서 그룹 조회 결과) 우선 사용
const externalValue = (() => {
if (groupedData && groupedData.length > 0) {
return groupedData;
}
return (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
})();
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지) // 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
const isEmptyRow = (item: any): boolean => { const isEmptyRow = (item: any): boolean => {

View File

@ -1,73 +1,12 @@
"use client"; "use client";
import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react"; import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component"; import { ComponentCategory } from "@/types/component";
import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridComponent } from "./PivotGridComponent";
import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
import { PivotFieldConfig } from "./types"; import { PivotFieldConfig } from "./types";
import { dataApi } from "@/lib/api/data";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
// ==================== 에러 경계 ====================
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class PivotGridErrorBoundary extends Component<
{ children: ReactNode; onReset?: () => void },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode; onReset?: () => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("🔴 [PivotGrid] 렌더링 에러:", error);
console.error("🔴 [PivotGrid] 에러 정보:", errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: undefined });
this.props.onReset?.();
};
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center border border-destructive/50 rounded-lg bg-destructive/5">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<h3 className="text-sm font-medium text-destructive mb-1">
</h3>
<p className="text-xs text-muted-foreground mb-3 max-w-md">
{this.state.error?.message || "알 수 없는 오류가 발생했습니다."}
</p>
<Button
variant="outline"
size="sm"
onClick={this.handleReset}
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
);
}
return this.props.children;
}
}
// ==================== 샘플 데이터 (미리보기용) ==================== // ==================== 샘플 데이터 (미리보기용) ====================
@ -156,63 +95,43 @@ const PivotGridWrapper: React.FC<any> = (props) => {
const configFields = componentConfig.fields || props.fields; const configFields = componentConfig.fields || props.fields;
const configData = props.data; const configData = props.data;
// 🆕 테이블에서 데이터 자동 로딩 // 디버깅 로그
const [loadedData, setLoadedData] = useState<any[]>([]); console.log("🔷 PivotGridWrapper props:", {
const [isLoading, setIsLoading] = useState(false); isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
useEffect(() => { hasComponentConfig: !!props.componentConfig,
const loadTableData = async () => { hasConfig: !!props.config,
const tableName = componentConfig.dataSource?.tableName; hasData: !!configData,
dataLength: configData?.length,
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음 hasFields: !!configFields,
if (configData || !tableName || props.isDesignMode) { fieldsLength: configFields?.length,
return; });
}
setIsLoading(true);
try {
const response = await dataApi.getTableData(tableName, {
page: 1,
size: 10000, // 피벗 분석용 대량 데이터
});
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
if (response.data && Array.isArray(response.data)) {
setLoadedData(response.data);
} else {
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
setLoadedData([]);
}
} catch (error) {
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
} finally {
setIsLoading(false);
}
};
loadTableData();
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
// 디자인 모드 판단: // 디자인 모드 판단:
// 1. isDesignMode === true // 1. isDesignMode === true
// 2. isInteractive === false (편집 모드) // 2. isInteractive === false (편집 모드)
// 3. 데이터가 없는 경우
const isDesignMode = props.isDesignMode === true || props.isInteractive === false; const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
const actualData = configData || loadedData;
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0; const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용 // 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
const usePreviewData = isDesignMode || (!hasValidData && !isLoading); const usePreviewData = isDesignMode || !hasValidData;
// 최종 데이터/필드 결정 // 최종 데이터/필드 결정
const finalData = usePreviewData ? SAMPLE_DATA : actualData; const finalData = usePreviewData ? SAMPLE_DATA : configData;
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS; const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
const finalTitle = usePreviewData const finalTitle = usePreviewData
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title); : (componentConfig.title || props.title);
console.log("🔷 PivotGridWrapper final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정 // 총계 설정
const totalsConfig = componentConfig.totals || props.totals || { const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true, showRowGrandTotals: true,
@ -221,39 +140,24 @@ const PivotGridWrapper: React.FC<any> = (props) => {
showColumnTotals: true, showColumnTotals: true,
}; };
// 🆕 로딩 중 표시
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
);
}
// 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함
return ( return (
<PivotGridErrorBoundary> <PivotGridComponent
<PivotGridComponent title={finalTitle}
title={finalTitle} data={finalData}
data={finalData} fields={finalFields}
fields={finalFields} totals={totalsConfig}
totals={totalsConfig} style={componentConfig.style || props.style}
style={componentConfig.style || props.style} fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser} chart={componentConfig.chart || props.chart}
chart={componentConfig.chart || props.chart} allowExpandAll={componentConfig.allowExpandAll !== false}
allowExpandAll={componentConfig.allowExpandAll !== false} height={componentConfig.height || props.height || "400px"}
height="100%" maxHeight={componentConfig.maxHeight || props.maxHeight}
maxHeight={componentConfig.maxHeight || props.maxHeight} exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }} onCellClick={props.onCellClick}
onCellClick={props.onCellClick} onCellDoubleClick={props.onCellDoubleClick}
onCellDoubleClick={props.onCellDoubleClick} onFieldDrop={props.onFieldDrop}
onFieldDrop={props.onFieldDrop} onExpandChange={props.onExpandChange}
onExpandChange={props.onExpandChange} />
/>
</PivotGridErrorBoundary>
); );
}; };
@ -319,6 +223,18 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
const componentConfig = props.componentConfig || props.config || {}; const componentConfig = props.componentConfig || props.config || {};
const configFields = componentConfig.fields || props.fields; const configFields = componentConfig.fields || props.fields;
const configData = props.data; const configData = props.data;
// 디버깅 로그
console.log("🔷 PivotGridRenderer props:", {
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive,
hasComponentConfig: !!props.componentConfig,
hasConfig: !!props.config,
hasData: !!configData,
dataLength: configData?.length,
hasFields: !!configFields,
fieldsLength: configFields?.length,
});
// 디자인 모드 판단: // 디자인 모드 판단:
// 1. isDesignMode === true // 1. isDesignMode === true
@ -338,6 +254,13 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
: (componentConfig.title || props.title); : (componentConfig.title || props.title);
console.log("🔷 PivotGridRenderer final:", {
isDesignMode,
usePreviewData,
finalDataLength: finalData?.length,
finalFieldsLength: finalFields?.length,
});
// 총계 설정 // 총계 설정
const totalsConfig = componentConfig.totals || props.totals || { const totalsConfig = componentConfig.totals || props.totals || {
showRowGrandTotals: true, showRowGrandTotals: true,
@ -356,7 +279,7 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
fieldChooser={componentConfig.fieldChooser || props.fieldChooser} fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart} chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false} allowExpandAll={componentConfig.allowExpandAll !== false}
height="100%" height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight} maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }} exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick} onCellClick={props.onCellClick}

View File

@ -1,213 +0,0 @@
"use client";
/**
* PivotGrid
* , , /
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, AggregationType } from "../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;

View File

@ -94,15 +94,6 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" }, { value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
]; ];
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "그룹 없음" },
{ value: "year", label: "년" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", label: "일" },
];
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = { const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
string: <Type className="h-3.5 w-3.5" />, string: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />, number: <Hash className="h-3.5 w-3.5" />,
@ -267,9 +258,11 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
const existingConfig = selectedFields.find((f) => f.field === field.field); const existingConfig = selectedFields.find((f) => f.field === field.field);
if (area === "none") { if (area === "none") {
// 필드 완전 제거 (visible: false 대신 배열에서 제거) // 필드 제거 또는 숨기기
if (existingConfig) { if (existingConfig) {
const newFields = selectedFields.filter((f) => f.field !== field.field); const newFields = selectedFields.map((f) =>
f.field === field.field ? { ...f, visible: false } : f
);
onFieldsChange(newFields); onFieldsChange(newFields);
} }
} else { } else {
@ -399,7 +392,7 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
</div> </div>
{/* 필드 목록 */} {/* 필드 목록 */}
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto"> <ScrollArea className="flex-1 -mx-6 px-6">
<div className="space-y-2 py-2"> <div className="space-y-2 py-2">
{filteredFields.length === 0 ? ( {filteredFields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">

View File

@ -2,7 +2,7 @@
/** /**
* FieldPanel * FieldPanel
* (, , ) * (, , , )
* *
*/ */
@ -25,7 +25,6 @@ import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PivotFieldConfig, PivotAreaType } from "../types"; import { PivotFieldConfig, PivotAreaType } from "../types";
@ -245,31 +244,22 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
const areaFields = fields.filter((f) => f.area === area && f.visible !== false); const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
const fieldIds = areaFields.map((f) => `${area}-${f.field}`); const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
// 🆕 드롭 가능 영역 설정
const { setNodeRef, isOver: isOverDroppable } = useDroppable({
id: area, // "filter", "column", "row", "data"
});
const finalIsOver = isOver || isOverDroppable;
return ( return (
<div <div
ref={setNodeRef}
className={cn( className={cn(
"flex-1 min-h-[60px] rounded border-2 border-dashed p-2", "flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
"transition-all duration-200", "transition-colors duration-200",
config.color, config.color,
finalIsOver && "border-primary bg-primary/10 scale-[1.02]", isOver && "border-primary bg-primary/5"
areaFields.length === 0 && "border-2" // 빈 영역일 때 테두리 강조
)} )}
data-area={area} data-area={area}
> >
{/* 영역 헤더 */} {/* 영역 헤더 */}
<div className="flex items-center gap-1 mb-1.5 text-xs font-semibold text-muted-foreground"> <div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
{icon} {icon}
<span>{title}</span> <span>{title}</span>
{areaFields.length > 0 && ( {areaFields.length > 0 && (
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded"> <span className="text-[10px] bg-muted px-1 rounded">
{areaFields.length} {areaFields.length}
</span> </span>
)} )}
@ -277,16 +267,11 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */} {/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}> <SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1 min-h-[28px] relative"> <div className="flex flex-wrap gap-1.5 min-h-[28px]">
{areaFields.length === 0 ? ( {areaFields.length === 0 ? (
<div <span className="text-xs text-muted-foreground/50 italic">
className="flex items-center justify-center w-full py-1 pointer-events-none"
style={{ pointerEvents: 'none' }} </span>
>
<span className="text-xs text-muted-foreground/70 italic font-medium">
</span>
</div>
) : ( ) : (
areaFields.map((field) => ( areaFields.map((field) => (
<SortableFieldChip <SortableFieldChip
@ -354,16 +339,8 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
return; return;
} }
// 드롭 영역 감지 (영역 자체의 ID를 우선 확인) // 드롭 영역 감지
const overId = over.id as string; const overId = over.id as string;
// 1. overId가 영역 자체인 경우 (filter, column, row, data)
if (["filter", "column", "row", "data"].includes(overId)) {
setOverArea(overId as PivotAreaType);
return;
}
// 2. overId가 필드인 경우 (예: row-part_name)
const targetArea = overId.split("-")[0] as PivotAreaType; const targetArea = overId.split("-")[0] as PivotAreaType;
if (["filter", "column", "row", "data"].includes(targetArea)) { if (["filter", "column", "row", "data"].includes(targetArea)) {
setOverArea(targetArea); setOverArea(targetArea);
@ -373,13 +350,10 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
// 드래그 종료 // 드래그 종료
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장
setActiveId(null); setActiveId(null);
setOverArea(null); setOverArea(null);
if (!over) { if (!over) return;
return;
}
const activeId = active.id as string; const activeId = active.id as string;
const overId = over.id as string; const overId = over.id as string;
@ -389,16 +363,7 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
PivotAreaType, PivotAreaType,
string string
]; ];
const [targetArea] = overId.split("-") as [PivotAreaType, string];
// targetArea 결정: handleDragOver에서 감지한 영역 우선 사용
let targetArea: PivotAreaType;
if (currentOverArea) {
targetArea = currentOverArea;
} else if (["filter", "column", "row", "data"].includes(overId)) {
targetArea = overId as PivotAreaType;
} else {
targetArea = overId.split("-")[0] as PivotAreaType;
}
// 같은 영역 내 정렬 // 같은 영역 내 정렬
if (sourceArea === targetArea) { if (sourceArea === targetArea) {
@ -441,7 +406,6 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
} }
return f; return f;
}); });
onFieldsChange(newFields); onFieldsChange(newFields);
} }
}; };
@ -479,42 +443,16 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
? fields.find((f) => `${f.area}-${f.field}` === activeId) ? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null; : null;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) { if (collapsed) {
return ( return (
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10"> <div className="border-b border-border px-3 py-2">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs h-6 px-2" className="text-xs"
> >
</Button> </Button>
</div> </div>
); );
@ -528,9 +466,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="border-b border-border bg-muted/20 p-2"> <div className="border-b border-border bg-muted/20 p-3">
{/* 4개 영역 배치: 2x2 그리드 */} {/* 2x2 그리드로 영역 배치 */}
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-2">
{/* 필터 영역 */} {/* 필터 영역 */}
<DroppableArea <DroppableArea
area="filter" area="filter"
@ -578,12 +516,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
{/* 접기 버튼 */} {/* 접기 버튼 */}
{onToggleCollapse && ( {onToggleCollapse && (
<div className="flex justify-center mt-1.5"> <div className="flex justify-center mt-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs h-5 px-2" className="text-xs h-6"
> >
</Button> </Button>

View File

@ -7,5 +7,4 @@ export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal"; export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup"; export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart"; export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";

View File

@ -51,18 +51,14 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe
// 보이는 아이템 수 // 보이는 아이템 수
const visibleCount = Math.ceil(containerHeight / itemHeight); const visibleCount = Math.ceil(containerHeight / itemHeight);
// 시작/끝 인덱스 계산 (음수 방지) // 시작/끝 인덱스 계산
const { startIndex, endIndex } = useMemo(() => { const { startIndex, endIndex } = useMemo(() => {
// itemCount가 0이면 빈 배열
if (itemCount === 0) {
return { startIndex: 0, endIndex: -1 };
}
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const end = Math.min( const end = Math.min(
itemCount - 1, itemCount - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
); );
return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록 return { startIndex: start, endIndex: end };
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
// 전체 높이 // 전체 높이

Some files were not shown because too many files have changed in this diff Show More