Compare commits

..

No commits in common. "main" and "feature/screen-management" have entirely different histories.

97 changed files with 2895 additions and 33966 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 @@
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 기능
### 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 지원)
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요
현재 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==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2372,7 +2371,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3476,7 +3474,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3713,7 +3710,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3931,7 +3927,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4458,7 +4453,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"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.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -7432,7 +7425,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8402,6 +8394,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9290,7 +9283,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10141,6 +10133,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10949,7 +10942,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11055,7 +11047,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

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

View File

@ -553,24 +553,10 @@ export const setUserLocale = async (
const { locale } = req.body;
if (!locale) {
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
res.status(400).json({
success: false,
message: "로케일이 필요합니다.",
});
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}`,
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
});
return;
}
@ -1179,33 +1165,6 @@ export async function saveMenu(
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> = {
success: true,
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>(
`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)]
);
@ -1547,50 +1437,67 @@ export async function deleteMenu(
}
}
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
const menuObjid = Number(menuId);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(menuObjid);
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
menuName: currentMenu.menu_name_kor,
totalCount: allMenuIdsToDelete.length,
childMenuIds,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
// 2. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
for (const objid of reversedIds) {
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
}
// 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]
);
logger.info("메뉴 삭제 성공", {
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor,
totalDeleted: allMenuIdsToDelete.length,
});
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
// Raw Query를 사용한 메뉴 삭제
const [deletedMenu] = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid]
);
logger.info("메뉴 삭제 성공", { deletedMenu });
const response: ApiResponse<any> = {
success: true,
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
message: "메뉴가 성공적으로 삭제되었습니다.",
data: {
objid: menuObjid.toString(),
menuNameKor: currentMenu.menu_name_kor,
deletedCount: allMenuIdsToDelete.length,
deletedChildCount: childMenuIds.length,
objid: deletedMenu.objid.toString(),
menuNameKor: deletedMenu.menu_name_kor,
menuNameEng: deletedMenu.menu_name_eng,
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를 사용한 메뉴 일괄 삭제
let deletedCount = 0;
let failedCount = 0;
const deletedMenus: any[] = [];
const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도
for (const menuObjid of reversedIds) {
for (const menuId of menuIds) {
try {
const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid]
[Number(menuId)]
);
if (result.length > 0) {
@ -1728,20 +1604,20 @@ export async function deleteMenusBatch(
});
} else {
failedCount++;
failedMenuIds.push(String(menuObjid));
failedMenuIds.push(menuId);
}
} catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
failedCount++;
failedMenuIds.push(String(menuObjid));
failedMenuIds.push(menuId);
}
}
logger.info("메뉴 일괄 삭제 완료", {
requested: menuIds.length,
totalWithChildren: allIdsArray.length,
total: menuIds.length,
deletedCount,
failedCount,
deletedMenus,
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("회사 등록 성공", {
companyCode: createdCompany.company_code,
companyName: createdCompany.company_name,
@ -3200,23 +3058,6 @@ export const updateProfile = async (
}
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}`);
updateValues.push(locale);
paramIndex++;

View File

@ -30,6 +30,7 @@ export class EntityJoinController {
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@ -49,6 +50,9 @@ export class EntityJoinController {
// search가 문자열인 경우 JSON 파싱
searchConditions =
typeof search === "string" ? JSON.parse(search) : search;
// 🔍 디버그: 파싱된 검색 조건 로깅
logger.info(`🔍 파싱된 검색 조건:`, JSON.stringify(searchConditions, null, 2));
} catch (error) {
logger.warn("검색 조건 파싱 오류:", error);
searchConditions = {};
@ -66,23 +70,11 @@ export class EntityJoinController {
const userField = parsedAutoFilter.userField || "companyCode";
const userValue = ((req as any).user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
searchConditions[filterColumn] = finalCompanyCode;
if (userValue) {
searchConditions[filterColumn] = userValue;
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
filterColumn,
finalCompanyCode,
userValue,
tableName,
});
}
@ -151,6 +143,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(
tableName,
{
@ -168,13 +178,26 @@ export class EntityJoinController {
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
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({
success: true,
message: "Entity 조인 데이터 조회 성공",
data: result,
data: finalData,
});
} catch (error) {
logger.error("Entity 조인 데이터 조회 실패", error);
@ -549,6 +572,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();

View File

@ -10,10 +10,7 @@ import {
SaveLangTextsRequest,
GetUserTextParams,
BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse,
LangCategory,
} from "../types/multilang";
/**
@ -190,7 +187,7 @@ export const getLangKeys = async (
res: Response
): Promise<void> => {
try {
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
const { companyCode, menuCode, keyType, searchText } = req.query;
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
@ -202,7 +199,6 @@ export const getLangKeys = async (
menuCode: menuCode as string,
keyType: keyType as string,
searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
});
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
* 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 userValue = (req.user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
if (userValue && userValue !== "*") {
enhancedSearch[filterColumn] = userValue;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue: finalCompanyCode,
userValue,
tableName,
});
} else if (userValue === "*") {
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
tableName,
});
} else {
@ -804,6 +797,9 @@ export async function getTableData(
}
}
// 🆕 최종 검색 조건 로그
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
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}`);
const response: ApiResponse<null> = {
// 무시된 컬럼이 있으면 경고 정보 포함
const response: ApiResponse<{
skippedColumns?: string[];
savedColumns?: string[];
}> = {
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);
@ -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(
req: AuthenticatedRequest,
@ -2190,93 +2199,53 @@ export async function getTableEntityRelations(
try {
const { leftTable, rightTable } = req.query;
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
if (!leftTable || !rightTable) {
res.status(400).json({
const response: ApiResponse<null> = {
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
});
error: {
code: "MISSING_PARAMETERS",
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
const tableManagementService = new TableManagementService();
const relations = await tableManagementService.detectTableEntityRelations(
String(leftTable),
String(rightTable)
);
// 두 테이블의 컬럼 라벨 정보 조회
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')
`;
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
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({
const response: ApiResponse<any> = {
success: true,
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
data: {
leftTable,
rightTable,
leftTable: String(leftTable),
rightTable: String(rightTable),
relations,
},
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
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

@ -21,20 +21,6 @@ import {
getUserText,
getLangText,
getBatchTranslations,
// 카테고리 관리 API
getCategories,
getCategoryById,
getCategoryPath,
// 자동 생성 및 오버라이드 API
generateKey,
previewKey,
createOverrideKey,
getOverrideKeys,
// 화면 라벨 다국어 API
generateScreenLabelKeys,
} from "../controllers/multilangController";
const router = express.Router();
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
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;

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 !== "offset" &&
key !== "orderBy" &&
key !== "userLang" &&
key !== "page" &&
key !== "pageSize" &&
key !== "size"
key !== "userLang"
) {
// 컬럼명 검증 (SQL 인젝션 방지)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {

View File

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

@ -2597,10 +2597,10 @@ export class ScreenManagementService {
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
// 3. 화면 코드 중복 체크 (대상 회사 기준)
const existingScreens = await client.query<any>(
`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`,
[copyData.screenCode, targetCompanyCode]
);

View File

@ -1314,7 +1314,7 @@ export class TableManagementService {
// 각 값을 LIKE 또는 = 조건으로 처리
const conditions: string[] = [];
const values: any[] = [];
value.forEach((v: any, idx: number) => {
const safeValue = String(v).trim();
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
@ -1323,24 +1323,17 @@ export class TableManagementService {
// - "2," 로 시작
// - ",2" 로 끝남
// - ",2," 중간에 포함
const paramBase = paramIndex + idx * 4;
const paramBase = paramIndex + (idx * 4);
conditions.push(`(
${columnName}::text = $${paramBase} OR
${columnName}::text LIKE $${paramBase + 1} OR
${columnName}::text LIKE $${paramBase + 2} OR
${columnName}::text LIKE $${paramBase + 3}
)`);
values.push(
safeValue,
`${safeValue},%`,
`%,${safeValue}`,
`%,${safeValue},%`
);
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
});
logger.info(
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
);
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
return {
whereClause: `(${conditions.join(" OR ")})`,
values,
@ -1779,29 +1772,21 @@ export class TableManagementService {
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
const referenceColumn = entityTypeInfo.referenceColumn || "id";
const referenceTable = entityTypeInfo.referenceTable;
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
let displayColumn = entityTypeInfo.displayColumn;
if (
!displayColumn ||
displayColumn === "none" ||
displayColumn === ""
) {
displayColumn = await this.findDisplayColumnForTable(
referenceTable,
referenceColumn
);
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
logger.info(
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
);
}
// 참조 테이블의 표시 컬럼으로 검색
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
return {
whereClause: `EXISTS (
SELECT 1 FROM ${referenceTable} ref
WHERE ref.${referenceColumn} = main.${columnName}
WHERE ref.${referenceColumn} = ${columnName}
AND ref.${displayColumn} ILIKE $${paramIndex}
)`,
values: [`%${value}%`],
@ -2165,14 +2150,14 @@ export class TableManagementService {
// 안전한 테이블명 검증
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 total = parseInt(countResult[0].count);
// 데이터 조회 (main 별칭 추가)
// 데이터 조회
const dataQuery = `
SELECT main.* FROM ${safeTableName} main
SELECT * FROM ${safeTableName}
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
@ -2509,7 +2494,7 @@ export class TableManagementService {
skippedColumns.push(column);
return;
}
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@ -2521,9 +2506,7 @@ export class TableManagementService {
});
if (skippedColumns.length > 0) {
logger.info(
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
);
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
@ -2793,14 +2776,10 @@ export class TableManagementService {
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
baseJoinConfig = joinConfigs.find(
(config) =>
config.referenceTable ===
(additionalColumn as any).referenceTable
(config) => config.referenceTable === (additionalColumn as any).referenceTable
);
if (baseJoinConfig) {
logger.info(
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`
);
logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable}${baseJoinConfig.sourceColumn}`);
}
}
@ -2808,31 +2787,25 @@ export class TableManagementService {
// joinAlias에서 실제 컬럼명 추출
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_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}_`,
""
);
actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, "");
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
// 실제 소스 컬럼으로 시작하면 그 부분 제거
actualColumnName = originalJoinAlias.replace(
`${sourceColumn}_`,
""
);
actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, "");
} else {
// 어느 것도 아니면 원본 사용
actualColumnName = originalJoinAlias;
}
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
@ -3226,10 +3199,8 @@ export class TableManagementService {
}
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
const allEntityColumns = [
...joinConfigs.map((config) => config.aliasColumn),
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
...joinConfigs.flatMap((config) => {
const additionalColumns = [];
@ -3635,10 +3606,8 @@ export class TableManagementService {
});
// main. 접두사 추가 (조인 쿼리용)
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
condition = condition.replace(
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
new RegExp(`\\b${columnName}\\b`, "g"),
`main.${columnName}`
);
conditions.push(condition);
@ -3840,12 +3809,9 @@ export class TableManagementService {
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
const companySpecificTables = [
"supplier_mng",
"customer_mng",
"customer_mng",
"item_info",
"dept_info",
"sales_order_mng", // 🔧 수주관리 테이블 추가
"sales_order_detail", // 🔧 수주상세 테이블 추가
"partner_info", // 🔧 거래처 테이블 추가
// 필요시 추가
];
@ -4756,7 +4722,7 @@ export class TableManagementService {
/**
*
* column_labels에서 .
*
*
* @param leftTable
* @param rightTable
* @returns
@ -4764,20 +4730,16 @@ export class TableManagementService {
async detectTableEntityRelations(
leftTable: string,
rightTable: string
): Promise<
Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>
> {
): Promise<Array<{
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
}>> {
try {
logger.info(
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
);
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
const relations: Array<{
leftColumn: string;
rightColumn: string;
@ -4844,17 +4806,12 @@ export class TableManagementService {
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
relations.forEach((rel, idx) => {
logger.info(
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
);
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
});
return relations;
} catch (error) {
logger.error(
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
error
);
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
return [];
}
}

View File

@ -17,30 +17,12 @@ export interface LangKey {
langKey: string;
description?: string;
isActive: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdDate?: Date;
createdBy?: string;
updatedDate?: Date;
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 {
textId?: number;
keyId: number;
@ -81,38 +63,10 @@ export interface CreateLangKeyRequest {
langKey: string;
description?: string;
isActive?: string;
categoryId?: number;
keyMeaning?: string;
usageNote?: string;
baseKeyId?: number;
createdBy?: 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 {
companyCode?: string;
menuName?: string;
@ -136,8 +90,6 @@ export interface GetLangKeysParams {
menuCode?: string;
keyType?: string;
searchText?: string;
categoryId?: number;
includeOverrides?: boolean;
page?: 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";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
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 { 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 ViewMode = "tree" | "table";
export default function ScreenManagementPage() {
const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState<Step>("list");
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 [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 stepConfig = {
list: {
title: "화면 목록 관리",
description: "생성된 화면들을 확인하고 관리하세요",
},
design: {
title: "화면 설계",
description: "드래그앤드롭으로 화면을 설계하세요",
},
template: {
title: "템플릿 관리",
description: "화면 템플릿을 관리하고 재사용하세요",
},
};
// 다음 단계로 이동
const goToNextStep = (nextStep: Step) => {
setStepHistory((prev) => [...prev, 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) => {
setCurrentStep(step);
// 해당 단계까지의 히스토리만 유지
const stepIndex = stepHistory.findIndex((s) => s === step);
if (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) {
return (
<div className="fixed inset-0 z-50 bg-background">
@ -131,119 +72,59 @@ export default function ScreenManagementPage() {
}
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
</div>
<div className="flex items-center gap-2">
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> 릿 </p>
</div>
{/* 단계별 내용 */}
<div className="flex-1">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<ScreenList
onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen}
onDesignScreen={(screen) => {
setSelectedScreen(screen);
goToNextStep("design");
}}
/>
)}
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-6">
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button
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>
{/* 메인 콘텐츠 */}
{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 버튼 */}
<ScrollToTop />
</div>
);
}

View File

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

View File

@ -33,17 +33,8 @@ function ScreenViewPage() {
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
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();
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
const companyCode = previewCompanyCode || authCompanyCode;
const { user, userName, companyCode } = useAuth();
// 🆕 모바일 환경 감지
const { isMobile } = useResponsive();
@ -113,7 +104,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
@ -242,40 +233,27 @@ function ScreenViewPage() {
const designWidth = layout?.screenResolution?.width || 1200;
const designHeight = layout?.screenResolution?.height || 800;
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
let containerWidth: number;
let containerHeight: number;
if (isPreviewMode) {
// iframe에서는 window 크기를 직접 사용
containerWidth = window.innerWidth;
containerHeight = window.innerHeight;
} else {
containerWidth = containerRef.current.offsetWidth;
containerHeight = containerRef.current.offsetHeight;
}
// 컨테이너의 실제 크기
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
let newScale: number;
if (isPreviewMode) {
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
const scaleX = containerWidth / designWidth;
const scaleY = containerHeight / designHeight;
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
} else {
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
// 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8)
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
newScale = availableWidth / designWidth;
}
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
const newScale = availableWidth / designWidth;
// console.log("📐 스케일 계산:", {
// containerWidth,
// containerHeight,
// MARGIN_X,
// availableWidth,
// designWidth,
// designHeight,
// finalScale: newScale,
// isPreviewMode,
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
// });
setScale(newScale);
@ -294,7 +272,7 @@ function ScreenViewPage() {
return () => {
clearTimeout(timer);
};
}, [layout, isMobile, isPreviewMode]);
}, [layout, isMobile]);
if (loading) {
return (
@ -332,7 +310,7 @@ function ScreenViewPage() {
<ScreenPreviewProvider isPreviewMode={false}>
<ActiveTabProvider>
<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 && (
<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;
}
/* ===== 저장 테이블 막대기 애니메이션 ===== */
@keyframes saveBarDrop {
0% {
transform: scaleY(0);
transform-origin: top;
opacity: 0;
}
/* ===== POP (Production Operation Panel) Styles ===== */
/* POP 전용 다크 테마 변수 */
.pop-dark {
/* 배경 색상 */
--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% {
transform: scaleY(1);
transform-origin: top;
opacity: 1;
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
}
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 ===== */

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

@ -65,10 +65,6 @@ const nodeTypes = {
*/
interface FlowEditorInnerProps {
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 { screenToFlowPosition, setCenter } = useReactFlow();
@ -389,7 +385,7 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
{/* 상단 툴바 */}
<Panel position="top-center" className="pointer-events-auto">
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
<FlowToolbar validations={validations} />
</Panel>
</ReactFlow>
</div>
@ -420,21 +416,13 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
*/
interface FlowEditorProps {
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 (
<div className="h-full w-full">
<ReactFlowProvider>
<FlowEditorInner
initialFlowId={initialFlowId}
onSaveComplete={onSaveComplete}
embedded={embedded}
/>
<FlowEditorInner initialFlowId={initialFlowId} />
</ReactFlowProvider>
</div>
);

View File

@ -17,11 +17,9 @@ import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
onSaveComplete?: (flowId: number, flowName: string) => void;
}
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
@ -61,27 +59,13 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const result = await saveFlow();
if (result.success) {
toast({
title: "저장 완료",
title: "✅ 플로우 저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
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 {
toast({
title: "저장 실패",
title: "❌ 저장 실패",
description: result.message,
variant: "destructive",
});

View File

@ -302,9 +302,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
// 현재 모드에 따라 표시할 메뉴 결정
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
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 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
@ -16,14 +15,6 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
import { ProfileFormData } from "@/types/profile";
import { Separator } from "@/components/ui/separator";
import { VehicleRegisterData } from "@/lib/api/driver";
import { apiClient } from "@/lib/api/client";
// 언어 정보 타입
interface LanguageInfo {
langCode: string;
langName: string;
langNative: string;
}
// 운전자 정보 타입
export interface DriverInfo {
@ -157,46 +148,6 @@ export function ProfileModal({
onSave,
onAlertClose,
}: 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) => {
switch (status) {
@ -342,15 +293,10 @@ export function ProfileModal({
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
{languages.length > 0 ? (
languages.map((lang) => (
<SelectItem key={lang.langCode} value={lang.langCode}>
{lang.langNative} ({lang.langCode})
</SelectItem>
))
) : (
<SelectItem value="KR"> (KR)</SelectItem>
)}
<SelectItem value="KR"> (KR)</SelectItem>
<SelectItem value="US">English (US)</SelectItem>
<SelectItem value="JP"> (JP)</SelectItem>
<SelectItem value="CN"> (CN)</SelectItem>
</SelectContent>
</Select>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,6 @@ import {
Layout,
Monitor,
Square,
Languages,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -35,8 +34,6 @@ interface DesignerToolbarProps {
isSaving?: boolean;
showZoneBorders?: boolean;
onToggleZoneBorders?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
}
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
@ -53,8 +50,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
isSaving = false,
showZoneBorders = true,
onToggleZoneBorders,
onGenerateMultilang,
isGeneratingMultilang = false,
}) => {
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">
@ -231,20 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
<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">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>

View File

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

View File

@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
case "entity": {
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
entityName: config?.entityName,
displayField: config?.displayField,
valueField: config?.valueField,
multiple: config?.multiple,
defaultValue: config?.defaultValue,
},
});
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>,
return applyStyles(
<DynamicWebTypeRenderer
webType="entity"
config={widget.webTypeConfig}
props={{
component: widget,
value: currentValue,
onChange: (value: any) => updateFormData(fieldName, value),
onFormDataChange: updateFormData,
formData: formData,
readonly: readonly,
required: required,
placeholder: widget.placeholder || "엔티티를 선택하세요",
isInteractive: true,
className: "w-full h-full",
}}
/>,
);
}
@ -1709,7 +1676,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
try {
// console.log("🗑️ 삭제 실행:", { recordId, tableName, formData });
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName);
// screenId 전달하여 제어관리 실행 가능하도록 함
const result = await dynamicFormApi.deleteFormDataFromTable(recordId, tableName, screenInfo?.id);
if (result.success) {
alert("삭제되었습니다.");
@ -1909,27 +1877,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
};
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return applyStyles(
<button
<Button
onClick={handleButtonClick}
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 ${
hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
size="sm"
variant={config?.variant || "default"}
className="w-full"
style={{
height: "100%",
// 설정값이 있으면 우선 적용
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{label || "버튼"}
</button>
</Button>
);
}

View File

@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
isInteractive={true}
formData={formData}
originalData={originalData || undefined}
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
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 (
<button
<Button
onClick={handleClick}
variant={(config?.variant as any) || "default"}
size={(config?.size as any) || "default"}
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={{
// 컴포넌트 스타일 적용
...comp.style,
@ -858,7 +853,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}}
>
{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 (
<div className="relative">
<Button
onClick={handleClick}
disabled={isExecuting || disabled}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
variant={hasCustomColors ? undefined : (config?.variant || "default")}
variant={config?.variant || "default"}
className={cn(
"transition-all duration-200",
isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
// 커스텀 색상이 없을 때만 기본 스타일 적용
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
config?.backgroundColor && { backgroundColor: config.backgroundColor },
config?.textColor && { color: config.textColor },
config?.borderColor && { borderColor: config.borderColor },
)}
style={{
// 커스텀 색상이 있을 때만 인라인 스타일 적용
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
...(config?.textColor && { color: config.textColor }),
...(config?.borderColor && { borderColor: config.borderColor }),
backgroundColor: config?.backgroundColor,
color: config?.textColor,
borderColor: config?.borderColor,
}}
>
{/* 메인 버튼 내용 */}

View File

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

View File

@ -78,7 +78,6 @@ import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
import FloatingPanel from "./FloatingPanel";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
@ -145,8 +144,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
},
});
const [isSaving, setIsSaving] = useState(false);
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
// 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
@ -1450,101 +1447,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, [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(
(e: React.DragEvent, template: TemplateComponent) => {
@ -4315,9 +4217,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
/>
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
@ -5100,42 +4999,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
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>
</TableOptionsProvider>
</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 { screenApi } from "@/lib/api/screen";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
import { Layers } from "lucide-react";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic";
@ -95,11 +93,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 그룹 필터 관련 상태
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
const [groups, setGroups] = useState<ScreenGroup[]>([]);
const [loadingGroups, setLoadingGroups] = useState(false);
// 검색어 디바운스를 위한 타이머 ref
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 지연 - 빠른 응답)
useEffect(() => {
// 이전 타이머 취소
@ -250,11 +224,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
params.companyCode = selectedCompanyCode;
}
// 그룹 필터
if (selectedGroupId !== "all") {
params.groupId = selectedGroupId;
}
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
const resp = await screenApi.getScreens(params);
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
@ -287,7 +256,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
return () => {
abort = true;
};
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
const filteredScreens = screens; // 서버 필터 기준 사용
@ -702,25 +671,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</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="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

@ -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 { 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";
interface SlimToolbarProps {
@ -13,9 +13,6 @@ interface SlimToolbarProps {
onSave: () => void;
isSaving?: boolean;
onPreview?: () => void;
onGenerateMultilang?: () => void;
isGeneratingMultilang?: boolean;
onOpenMultilangSettings?: () => void;
}
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
@ -26,9 +23,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
onSave,
isSaving = false,
onPreview,
onGenerateMultilang,
isGeneratingMultilang = false,
onOpenMultilangSettings,
}) => {
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">
@ -76,29 +70,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
<span> </span>
</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">
<Save className="h-4 w-4" />
<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로 렌더링하여 버튼 동작 완전 차단
if (isDesignMode) {
return (
<div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
hasCustomColors ? '' : 'bg-blue-600 text-white'
} ${className || ""}`}
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
style={{
...style,
backgroundColor: bgColor,
color: textColor,
width: "100%",
height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시
@ -69,13 +56,9 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
type="button"
onClick={handleClick}
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 ${
hasCustomColors ? '' : 'bg-blue-600 text-white hover:bg-blue-700'
} ${className || ""}`}
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 || ""} `}
style={{
...style,
backgroundColor: bgColor,
color: textColor,
width: "100%",
height: "100%",
}}

View File

@ -16,9 +16,7 @@ import {
RepeaterItemData,
RepeaterFieldDefinition,
CalculationFormula,
SubDataState,
} from "@/types/repeater";
import { SubDataLookupPanel } from "@/lib/registry/components/repeater-field-group/SubDataLookupPanel";
import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -70,12 +68,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
layout = "grid", // 기본값을 grid로 설정
showDivider = true,
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
subDataLookup,
} = config;
// 하위 데이터 조회 상태 관리 (각 항목별)
const [subDataStates, setSubDataStates] = useState<Map<number, SubDataState>>(new Map());
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
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 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) => {
if (!allowReorder || readonly || disabled) return;
setDraggedIndex(index);
@ -500,26 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
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 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = {
value: value || "",
disabled: isReadonly || isConditionalDisabled,
placeholder: isConditionalDisabled ? "재고 선택 필요" : defaultPlaceholder,
disabled: isReadonly,
placeholder: defaultPlaceholder,
required: field.required,
};
@ -692,37 +569,23 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={maxValue !== null ? maxValue : field.validation?.max}
className={cn("pr-1", exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")}
max={field.validation?.max}
className="pr-1"
/>
{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>
);
}
return (
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={maxValue !== null ? maxValue : field.validation?.max}
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>
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="min-w-[80px]"
/>
);
case "email":
@ -891,9 +754,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 그리드/테이블 형식 렌더링
const renderGridLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return (
<div className="bg-card">
<Table>
@ -915,83 +775,55 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, itemIndex) => {
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
{items.map((item, itemIndex) => (
<TableRow
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}>
<TableRow
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>
)}
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
{/* 래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
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>
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
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>
)}
</React.Fragment>
);
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
@ -1000,15 +832,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 카드 형식 렌더링 (기존 방식)
const renderCardLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return (
<>
{items.map((item, itemIndex) => {
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
return (
<Card
@ -1080,21 +907,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</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>
)}

View File

@ -9,17 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator, Database, ArrowUp, ArrowDown } from "lucide-react";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import {
RepeaterFieldGroupConfig,
RepeaterFieldDefinition,
RepeaterFieldType,
CalculationOperator,
CalculationFormula,
SubDataLookupConfig,
} from "@/types/repeater";
import { apiClient } from "@/lib/api/client";
import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils";
@ -96,56 +93,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
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) => {
setLocalInputs((prev) => ({
@ -182,46 +129,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
const [tableSelectOpen, setTableSelectOpen] = useState(false);
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(() => {
if (!tableSearchValue) return allTables;
@ -239,86 +146,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
return table ? table.displayName || table.tableName : config.targetTable;
}, [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 (
<div className="space-y-4">
{/* 대상 테이블 선택 */}
@ -423,485 +250,24 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p>
</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="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<span className="text-xs text-gray-500"> </span>
</div>
<Label className="text-sm font-semibold"> </Label>
{localFields.map((field, index) => (
<Card
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}
>
<Card key={`${field.name}-${index}`} className="border-2">
<CardContent className="space-y-3 pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 cursor-move text-gray-400 hover:text-gray-600" />
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
</div>
<div className="flex items-center gap-1">
{/* 순서 변경 버튼 */}
<Button
type="button"
variant="ghost"
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>
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">

View File

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

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

@ -77,26 +77,21 @@ export const entityJoinApi = {
filterColumn?: string;
filterValue?: any;
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
companyCodeOverride?: string; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
deduplication?: {
enabled: boolean;
groupByColumn: string;
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
sortColumn?: string;
}; // 🆕 중복 제거 설정
} = {},
): Promise<EntityJoinResponse> => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
const autoFilter: {
enabled: boolean;
filterColumn: string;
userField: string;
companyCodeOverride?: string;
} = {
const autoFilter = {
enabled: true,
filterColumn: "company_code",
userField: "companyCode",
};
// 🆕 프리뷰 모드에서 회사 코드 오버라이드 (최고 관리자만 백엔드에서 허용)
if (params.companyCodeOverride) {
autoFilter.companyCodeOverride = params.companyCodeOverride;
}
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
params: {
page: params.page,
@ -107,9 +102,10 @@ export const entityJoinApi = {
search: params.search ? JSON.stringify(params.search) : undefined,
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : 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, // 🆕 데이터 필터
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
},
});
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;
},
// 화면 수정 (이름, 설명 등)
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> => {
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;
dbType: string;
webType: string;
inputType?: string; // text, number, entity, code, select, date, checkbox 등
inputType?: "direct" | "auto";
detailSettings: string;
description?: string;
isNullable: string;
@ -39,11 +39,11 @@ export interface TableInfo {
columnCount: number;
}
// 컬럼 설정 타입 (백엔드 API와 동일한 필드명 사용)
// 컬럼 설정 타입
export interface ColumnSettings {
columnName?: string;
columnLabel: string;
inputType: string; // 백엔드에서 inputType으로 받음
webType: string;
detailSettings: string;
codeCategory: string;
codeValue: string;

View File

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

View File

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

View File

@ -184,7 +184,7 @@ const DataCell: React.FC<DataCellProps> = ({
onClick={onClick}
onDoubleClick={onDoubleClick}
>
0
-
</td>
);
}
@ -222,7 +222,7 @@ const DataCell: React.FC<DataCellProps> = ({
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{values[0].formattedValue || (values[0].value === 0 ? '0' : values[0].formattedValue)}
{values[0].formattedValue}
</span>
</td>
);
@ -257,7 +257,7 @@ const DataCell: React.FC<DataCellProps> = ({
)}
<span className="relative z-10 flex items-center justify-end gap-1">
{icon && <span>{icon}</span>}
{val.formattedValue || (val.value === 0 ? '0' : val.formattedValue)}
{val.formattedValue}
</span>
</td>
))}
@ -296,6 +296,13 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldDrop,
onExpandChange,
}) => {
// 디버깅 로그
console.log("🔶 PivotGridComponent props:", {
title,
hasExternalData: !!externalData,
externalDataLength: externalData?.length,
initialFieldsLength: initialFields?.length,
});
// ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
@ -305,9 +312,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
sortConfig: null,
filterConfig: {},
});
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
const [showFieldChooser, setShowFieldChooser] = useState(false);
@ -366,63 +370,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage) - 프로덕션 안전성 강화
// 상태 복원 (localStorage)
useEffect(() => {
if (typeof window === "undefined") return;
try {
const savedState = localStorage.getItem(stateStorageKey);
if (!savedState) return;
const parsed = JSON.parse(savedState);
// 버전 체크 - 버전이 다르면 이전 상태 무시
if (parsed.version !== PIVOT_STATE_VERSION) {
localStorage.removeItem(stateStorageKey);
return;
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
if (parsed.fields) setFields(parsed.fields);
if (parsed.pivotState) setPivotState(parsed.pivotState);
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
if (parsed.columnWidths) setColumnWidths(parsed.columnWidths);
} catch (e) {
console.warn("피벗 상태 복원 실패:", e);
}
// 필드 복원 시 유효성 검사 (중요!)
if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) {
// 저장된 필드가 현재 데이터와 호환되는지 확인
const validFields = parsed.fields.filter((f: PivotFieldConfig) =>
f && typeof f.field === "string" && typeof f.area === "string"
);
if (validFields.length > 0) {
setFields(validFields);
}
}
// pivotState 복원 시 유효성 검사 (확장 경로 검증)
if (parsed.pivotState && typeof parsed.pivotState === "object") {
const restoredState: PivotGridState = {
// expandedRowPaths는 배열의 배열이어야 함
expandedRowPaths: Array.isArray(parsed.pivotState.expandedRowPaths)
? parsed.pivotState.expandedRowPaths.filter(
(p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string")
)
: [],
// expandedColumnPaths도 동일하게 검증
expandedColumnPaths: Array.isArray(parsed.pivotState.expandedColumnPaths)
? parsed.pivotState.expandedColumnPaths.filter(
(p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string")
)
: [],
sortConfig: parsed.pivotState.sortConfig || null,
filterConfig: parsed.pivotState.filterConfig || {},
};
setPivotState(restoredState);
}
if (parsed.sortConfig) setSortConfig(parsed.sortConfig);
if (parsed.columnWidths && typeof parsed.columnWidths === "object") {
setColumnWidths(parsed.columnWidths);
}
} catch (e) {
console.warn("피벗 상태 복원 실패, localStorage 초기화:", e);
// 손상된 상태는 제거
localStorage.removeItem(stateStorageKey);
}
}, [stateStorageKey]);
@ -457,12 +418,10 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 필터 영역 필드
const filterFields = useMemo(
() => {
const result = fields
() =>
fields
.filter((f) => f.area === "filter" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
return result;
},
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)),
[fields]
);
@ -507,86 +466,41 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
if (activeFilters.length === 0) return data;
const result = data.filter((row) => {
return data.filter((row) => {
return activeFilters.every((filter) => {
const rawValue = row[filter.field];
const value = row[filter.field];
const filterValues = filter.filterValues || [];
const filterType = filter.filterType || "include";
// 타입 안전한 비교: 값을 문자열로 변환하여 비교
const value = rawValue === null || rawValue === undefined
? "(빈 값)"
: String(rawValue);
if (filterType === "include") {
return filterValues.some((fv) => String(fv) === value);
return filterValues.includes(value);
} else {
return filterValues.every((fv) => String(fv) !== value);
return !filterValues.includes(value);
}
});
});
// 모든 데이터가 필터링되면 경고 (디버깅용)
if (result.length === 0 && data.length > 0) {
console.warn("⚠️ [PivotGrid] 필터로 인해 모든 데이터가 제거됨");
}
return result;
}, [data, fields]);
// ==================== 피벗 처리 ====================
const pivotResult = useMemo<PivotResult | null>(() => {
try {
if (!filteredData || filteredData.length === 0 || fields.length === 0) {
return null;
}
// FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요
// 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외)
if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) {
return null;
}
const result = processPivotData(
filteredData,
fields,
pivotState.expandedRowPaths,
pivotState.expandedColumnPaths
);
return result;
} catch (error) {
console.error("❌ [pivotResult] 피벗 처리 에러:", error);
if (!filteredData || filteredData.length === 0 || fields.length === 0) {
return null;
}
}, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
// 초기 로드 시 첫 레벨 자동 확장
useEffect(() => {
try {
if (pivotResult && pivotResult.flatRows && pivotResult.flatRows.length > 0 && !isInitialExpanded) {
// 첫 레벨 행들의 경로 수집 (level 0인 행들)
const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren);
// 첫 레벨 행이 있으면 자동 확장
if (firstLevelRows.length > 0 && firstLevelRows.length < 100) {
const firstLevelPaths = firstLevelRows.map((row) => row.path);
setPivotState((prev) => ({
...prev,
expandedRowPaths: firstLevelPaths,
}));
setIsInitialExpanded(true);
} else {
// 행이 너무 많으면 자동 확장 건너뛰기
setIsInitialExpanded(true);
}
}
} catch (error) {
console.error("❌ [초기 확장] 에러:", error);
setIsInitialExpanded(true);
const visibleFields = fields.filter((f) => f.visible !== false);
// 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외)
if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) {
return null;
}
}, [pivotResult, isInitialExpanded]);
return processPivotData(
filteredData,
visibleFields,
pivotState.expandedRowPaths,
pivotState.expandedColumnPaths
);
}, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]);
// 조건부 서식용 전체 값 수집
const allCellValues = useMemo(() => {
@ -777,52 +691,23 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
[onExpandChange]
);
// 전체 확장 (재귀적으로 모든 레벨 확장)
// 전체 확장
const handleExpandAll = useCallback(() => {
try {
if (!pivotResult) {
return;
if (!pivotResult) return;
const allRowPaths: string[][] = [];
pivotResult.flatRows.forEach((row) => {
if (row.hasChildren) {
allRowPaths.push(row.path);
}
});
// 재귀적으로 모든 가능한 경로 생성
const allRowPaths: string[][] = [];
const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false);
// 행 필드가 없으면 종료
if (rowFields.length === 0) {
return;
}
// 데이터에서 모든 고유한 경로 추출
const pathSet = new Set<string>();
filteredData.forEach((item) => {
// 마지막 레벨은 제외 (확장할 자식이 없으므로)
for (let depth = 1; depth < rowFields.length; depth++) {
const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? ""));
const pathKey = JSON.stringify(path);
pathSet.add(pathKey);
}
});
// Set을 배열로 변환 (최대 1000개로 제한하여 성능 보호)
const MAX_PATHS = 1000;
let count = 0;
pathSet.forEach((pathKey) => {
if (count < MAX_PATHS) {
allRowPaths.push(JSON.parse(pathKey));
count++;
}
});
setPivotState((prev) => ({
...prev,
expandedRowPaths: allRowPaths,
expandedColumnPaths: [],
}));
} catch (error) {
console.error("❌ [handleExpandAll] 에러:", error);
}
}, [pivotResult, fields, filteredData]);
setPivotState((prev) => ({
...prev,
expandedRowPaths: allRowPaths,
expandedColumnPaths: [],
}));
}, [pivotResult]);
// 전체 축소
const handleCollapseAll = useCallback(() => {
@ -945,8 +830,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함)
const handlePrint = useCallback(() => {
if (typeof window === "undefined") return;
const printContent = tableRef.current;
if (!printContent) return;
@ -1047,14 +930,10 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
console.log("피벗 상태가 저장되었습니다.");
}, [saveStateToStorage]);
// 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지)
// 상태 초기화
const handleResetState = useCallback(() => {
// 로컬 스토리지에서 상태 제거 (SSR 보호)
if (typeof window !== "undefined") {
localStorage.removeItem(stateStorageKey);
}
// 확장/축소, 정렬, 필터 상태만 초기화
localStorage.removeItem(stateStorageKey);
setFields(initialFields);
setPivotState({
expandedRowPaths: [],
expandedColumnPaths: [],
@ -1065,7 +944,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
setColumnWidths({});
setSelectedCell(null);
setSelectionRange(null);
}, [stateStorageKey]);
}, [stateStorageKey, initialFields]);
// 필드 숨기기/표시 상태
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
@ -1082,6 +961,11 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
});
}, []);
// 숨겨진 필드 제외한 활성 필드들
const visibleFields = useMemo(() => {
return fields.filter((f) => !hiddenFields.has(f.field));
}, [fields, hiddenFields]);
// 숨겨진 필드 목록
const hiddenFieldsList = useMemo(() => {
return fields.filter((f) => hiddenFields.has(f.field));
@ -1449,8 +1333,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleCollapseAll}
title="전체 축소"
onClick={handleExpandAll}
title="전체 확장"
>
<ChevronDown className="h-4 w-4" />
</Button>
@ -1459,8 +1343,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={handleExpandAll}
title="전체 확장"
onClick={handleCollapseAll}
title="전체 축소"
>
<ChevronRight className="h-4 w-4" />
</Button>
@ -1640,25 +1524,19 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
<button
className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded text-xs",
"border transition-colors max-w-xs",
"border transition-colors",
isFiltered
? "bg-orange-100 border-orange-300 text-orange-800 dark:bg-orange-900/30 dark:border-orange-700 dark:text-orange-200"
: "bg-background border-border hover:bg-accent"
)}
title={isFiltered ? `${filterField.caption}: ${selectedValues.join(", ")}` : filterField.caption}
>
<span className="font-medium">{filterField.caption}:</span>
{isFiltered ? (
<span className="truncate">
{selectedValues.length <= 2
? selectedValues.join(", ")
: `${selectedValues.slice(0, 2).join(", ")}${selectedValues.length - 2}`
}
<span>{filterField.caption}</span>
{isFiltered && (
<span className="bg-orange-500 text-white px-1 rounded text-[10px]">
{selectedValues.length}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
<ChevronDown className="h-3 w-3" />
</button>
}
/>
@ -1672,27 +1550,20 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
<div
ref={tableContainerRef}
className="flex-1 overflow-auto focus:outline-none"
style={{
maxHeight: enableVirtualScroll && containerHeight > 0 ? containerHeight : undefined,
// 최소 200px 보장 + 데이터에 맞게 조정 (최대 400px)
minHeight: Math.max(
200, // 절대 최소값 - 블라인드 효과 방지
Math.min(400, (sortedFlatRows.length + 3) * ROW_HEIGHT + 50)
)
}}
style={{ maxHeight: enableVirtualScroll ? containerHeight : undefined }}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<table ref={tableRef} className="w-full border-collapse">
<thead>
{/* 열 헤더 */}
<tr className="bg-background">
<tr className="bg-muted/50">
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
<th
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-left text-xs font-medium",
"bg-background sticky left-0 top-0 z-20"
"px-2 py-2 text-left text-xs font-medium",
"bg-muted sticky left-0 top-0 z-20"
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
@ -1736,8 +1607,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
key={idx}
className={cn(
"border-r border-b border-border relative group",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-muted/70 sticky top-0 z-10",
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={dataFields.length || 1}
@ -1759,31 +1630,16 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
/>
</th>
))}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
rowSpan={dataFields.length > 1 ? 2 : 1}
>
</th>
)}
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
{/* 열 필드 필터 (헤더 왼쪽에 표시) */}
{columnFields.length > 0 && (
<th
className={cn(
"border-b border-border",
"px-1 py-1 text-center text-xs",
"bg-background sticky top-0 z-10"
"px-1 py-1.5 text-center text-xs",
"bg-muted/50 sticky top-0 z-10"
)}
rowSpan={dataFields.length > 1 ? 2 : 1}
rowSpan={columnFields.length > 0 ? 2 : 1}
>
<div className="flex flex-col gap-0.5">
{columnFields.map((f) => (
@ -1815,11 +1671,25 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</div>
</th>
)}
{/* 행 총계 헤더 */}
{totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1.5 text-center text-xs font-medium",
"bg-primary/10 sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
>
</th>
)}
</tr>
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && (
<tr className="bg-background">
<tr className="bg-muted/30">
{flatColumns.map((col, colIdx) => (
<React.Fragment key={colIdx}>
{dataFields.map((df, dfIdx) => (
@ -1827,7 +1697,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
key={`${colIdx}-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-0.5 text-center text-xs font-normal",
"px-2 py-1 text-center text-xs font-normal",
"text-muted-foreground cursor-pointer hover:bg-accent/50"
)}
onClick={() => handleSort(df.field)}
@ -1840,6 +1710,19 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
))}
</React.Fragment>
))}
{totals?.showRowGrandTotals &&
dataFields.map((df, dfIdx) => (
<th
key={`total-${dfIdx}`}
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-center text-xs font-normal",
"bg-primary/5 text-muted-foreground"
)}
>
{df.caption}
</th>
))}
</tr>
)}
</thead>
@ -1954,15 +1837,12 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
});
})()}
{/* 가상 스크롤 하단 여백 - 음수 방지 */}
{enableVirtualScroll && (() => {
const bottomPadding = Math.max(0, virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT));
return bottomPadding > 0 ? (
<tr style={{ height: bottomPadding }}>
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
</tr>
) : null;
})()}
{/* 가상 스크롤 하단 여백 */}
{enableVirtualScroll && (
<tr style={{ height: virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT) }}>
<td colSpan={rowFields.length + flatColumns.length + (totals?.showRowGrandTotals ? dataFields.length : 0)} />
</tr>
)}
{/* 열 총계 행 (하단 위치 - 기본값) */}
{totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && (

View File

@ -1,73 +1,12 @@
"use client";
import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { PivotGridComponent } from "./PivotGridComponent";
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
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 configData = props.data;
// 🆕 테이블에서 데이터 자동 로딩
const [loadedData, setLoadedData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadTableData = async () => {
const tableName = componentConfig.dataSource?.tableName;
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
if (configData || !tableName || props.isDesignMode) {
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]);
// 디버깅 로그
console.log("🔷 PivotGridWrapper 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
// 2. isInteractive === false (편집 모드)
// 3. 데이터가 없는 경우
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
const actualData = configData || loadedData;
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
const hasValidData = configData && Array.isArray(configData) && configData.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 finalTitle = usePreviewData
? (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 || {
showRowGrandTotals: true,
@ -221,39 +140,24 @@ const PivotGridWrapper: React.FC<any> = (props) => {
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 (
<PivotGridErrorBoundary>
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height="100%"
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
</PivotGridErrorBoundary>
<PivotGridComponent
title={finalTitle}
data={finalData}
fields={finalFields}
totals={totalsConfig}
style={componentConfig.style || props.style}
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}
onCellDoubleClick={props.onCellDoubleClick}
onFieldDrop={props.onFieldDrop}
onExpandChange={props.onExpandChange}
/>
);
};
@ -319,6 +223,18 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
const componentConfig = props.componentConfig || props.config || {};
const configFields = componentConfig.fields || props.fields;
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
@ -338,6 +254,13 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
? (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 || {
showRowGrandTotals: true,
@ -356,7 +279,7 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
chart={componentConfig.chart || props.chart}
allowExpandAll={componentConfig.allowExpandAll !== false}
height="100%"
height={componentConfig.height || props.height || "400px"}
maxHeight={componentConfig.maxHeight || props.maxHeight}
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
onCellClick={props.onCellClick}

View File

@ -267,9 +267,11 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
const existingConfig = selectedFields.find((f) => f.field === field.field);
if (area === "none") {
// 필드 완전 제거 (visible: false 대신 배열에서 제거)
// 필드 제거 또는 숨기기
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);
}
} else {
@ -399,7 +401,7 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
</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">
{filteredFields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">

View File

@ -25,7 +25,6 @@ import {
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
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 fieldIds = areaFields.map((f) => `${area}-${f.field}`);
// 🆕 드롭 가능 영역 설정
const { setNodeRef, isOver: isOverDroppable } = useDroppable({
id: area, // "filter", "column", "row", "data"
});
const finalIsOver = isOver || isOverDroppable;
return (
<div
ref={setNodeRef}
className={cn(
"flex-1 min-h-[60px] rounded border-2 border-dashed p-2",
"transition-all duration-200",
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
"transition-colors duration-200",
config.color,
finalIsOver && "border-primary bg-primary/10 scale-[1.02]",
areaFields.length === 0 && "border-2" // 빈 영역일 때 테두리 강조
isOver && "border-primary bg-primary/5"
)}
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 mb-1 text-[11px] font-medium text-muted-foreground">
{icon}
<span>{title}</span>
{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}
</span>
)}
@ -277,16 +267,11 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1 min-h-[28px] relative">
<div className="flex flex-wrap gap-1 min-h-[22px]">
{areaFields.length === 0 ? (
<div
className="flex items-center justify-center w-full py-1 pointer-events-none"
style={{ pointerEvents: 'none' }}
>
<span className="text-xs text-muted-foreground/70 italic font-medium">
</span>
</div>
<span className="text-[10px] text-muted-foreground/50 italic">
</span>
) : (
areaFields.map((field) => (
<SortableFieldChip
@ -354,16 +339,8 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
return;
}
// 드롭 영역 감지 (영역 자체의 ID를 우선 확인)
// 드롭 영역 감지
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;
if (["filter", "column", "row", "data"].includes(targetArea)) {
setOverArea(targetArea);
@ -373,13 +350,10 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
// 드래그 종료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장
setActiveId(null);
setOverArea(null);
if (!over) {
return;
}
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
@ -389,16 +363,7 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
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;
}
const [targetArea] = overId.split("-") as [PivotAreaType, string];
// 같은 영역 내 정렬
if (sourceArea === targetArea) {
@ -441,7 +406,6 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
}
return f;
});
onFieldsChange(newFields);
}
};

View File

@ -51,18 +51,14 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe
// 보이는 아이템 수
const visibleCount = Math.ceil(containerHeight / itemHeight);
// 시작/끝 인덱스 계산 (음수 방지)
// 시작/끝 인덱스 계산
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 end = Math.min(
itemCount - 1,
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]);
// 전체 높이

View File

@ -710,19 +710,27 @@ export function processPivotData(
.filter((f) => f.area === "data" && f.visible !== false)
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
// 참고: 필터링은 PivotGridComponent에서 이미 처리됨
// 여기서는 추가 필터링 없이 전달받은 데이터 사용
const filteredData = data;
const filterFields = fields.filter(
(f) => f.area === "filter" && f.visible !== false
);
// 확장 경로 Set 변환 (잘못된 형식 필터링)
const validRowPaths = (expandedRowPaths || []).filter(
(p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string")
);
const validColPaths = (expandedColumnPaths || []).filter(
(p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string")
);
const expandedRowSet = new Set(validRowPaths.map(pathToKey));
const expandedColSet = new Set(validColPaths.map(pathToKey));
// 필터 적용
let filteredData = data;
for (const filterField of filterFields) {
if (filterField.filterValues && filterField.filterValues.length > 0) {
filteredData = filteredData.filter((row) => {
const value = getFieldValue(row, filterField);
if (filterField.filterType === "exclude") {
return !filterField.filterValues!.includes(value);
}
return filterField.filterValues!.includes(value);
});
}
}
// 확장 경로 Set 변환
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
// 기본 확장: 첫 번째 레벨 모두 확장
if (expandedRowPaths.length === 0 && rowFields.length > 0) {

View File

@ -5,8 +5,21 @@ import { Plus, X, Save, FolderOpen, RefreshCw, Eye, AlertCircle } from "lucide-r
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@/lib/utils";
@ -87,9 +100,9 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
const handleBlur = (field: keyof typeof localValues) => {
const numValue = parseInt(localValues[field]) || 0;
const clampedValue = Math.max(0, Math.min(numValue, field === "levels" ? maxLevels : maxRows));
setLocalValues((prev) => ({ ...prev, [field]: clampedValue.toString() }));
const updateField = field === "startRow" ? "startRow" : field === "endRow" ? "endRow" : "levels";
onUpdate(condition.id, { [updateField]: clampedValue });
};
@ -100,7 +113,10 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
<div className="flex items-center justify-between rounded-t-lg bg-blue-600 px-4 py-2 text-white">
<span className="font-medium"> {index + 1}</span>
{!readonly && (
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-blue-700">
<button
onClick={() => onRemove(condition.id)}
className="rounded p-1 transition-colors hover:bg-blue-700"
>
<X className="h-4 w-4" />
</button>
)}
@ -182,18 +198,20 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
tableName,
}) => {
// 조건 목록
const [conditions, setConditions] = useState<RackLineCondition[]>(config.initialConditions || []);
const [conditions, setConditions] = useState<RackLineCondition[]>(
config.initialConditions || []
);
// 템플릿 관련 상태
const [templates, setTemplates] = useState<RackStructureTemplate[]>([]);
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
const [templateName, setTemplateName] = useState("");
const [isSaveMode, setIsSaveMode] = useState(false);
// 미리보기 데이터
const [previewData, setPreviewData] = useState<GeneratedLocation[]>([]);
const [isPreviewGenerated, setIsPreviewGenerated] = useState(false);
// 기존 데이터 중복 체크 관련 상태
const [existingLocations, setExistingLocations] = useState<ExistingLocation[]>([]);
const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false);
@ -252,22 +270,19 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
}, [formData, fieldMapping]);
// 카테고리 코드를 라벨로 변환하는 헬퍼 함수
const getCategoryLabel = useCallback(
(value: string | undefined): string | undefined => {
if (!value) return undefined;
if (isCategoryCode(value)) {
return categoryLabels[value] || value;
}
return value;
},
[categoryLabels],
);
const getCategoryLabel = useCallback((value: string | undefined): string | undefined => {
if (!value) return undefined;
if (isCategoryCode(value)) {
return categoryLabels[value] || value;
}
return value;
}, [categoryLabels]);
// 필드 매핑을 통해 formData에서 컨텍스트 추출
const context: RackStructureContext = useMemo(() => {
// propContext가 있으면 우선 사용
if (propContext) return propContext;
// formData와 fieldMapping을 사용하여 컨텍스트 생성
if (!formData) return {};
@ -277,13 +292,22 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
const rawStatus = fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined;
const ctx = {
warehouseCode: fieldMapping.warehouseCodeField ? formData[fieldMapping.warehouseCodeField] : undefined,
warehouseName: fieldMapping.warehouseNameField ? formData[fieldMapping.warehouseNameField] : undefined,
// 카테고리 값은 라벨로 변환
warehouseCode: fieldMapping.warehouseCodeField
? formData[fieldMapping.warehouseCodeField]
: undefined,
warehouseName: fieldMapping.warehouseNameField
? formData[fieldMapping.warehouseNameField]
: undefined,
// 카테고리 값은 라벨로 변환 (화면 표시용)
floor: getCategoryLabel(rawFloor?.toString()),
zone: getCategoryLabel(rawZone),
locationType: getCategoryLabel(rawLocationType),
status: getCategoryLabel(rawStatus),
// 카테고리 코드 원본값 (DB 쿼리/저장용)
floorCode: rawFloor?.toString(),
zoneCode: rawZone?.toString(),
locationTypeCode: rawLocationType?.toString(),
statusCode: rawStatus?.toString(),
};
console.log("🏗️ [RackStructure] context 생성:", {
@ -313,24 +337,26 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 조건 추가
const addCondition = useCallback(() => {
if (conditions.length >= maxConditions) return;
// 마지막 조건의 다음 열부터 시작
const lastCondition = conditions[conditions.length - 1];
const startRow = lastCondition ? lastCondition.endRow + 1 : 1;
const newCondition: RackLineCondition = {
id: generateId(),
startRow,
endRow: startRow + 2,
levels: 3,
};
setConditions((prev) => [...prev, newCondition]);
}, [conditions, maxConditions]);
// 조건 업데이트
const updateCondition = useCallback((id: string, updates: Partial<RackLineCondition>) => {
setConditions((prev) => prev.map((cond) => (cond.id === id ? { ...cond, ...updates } : cond)));
setConditions((prev) =>
prev.map((cond) => (cond.id === id ? { ...cond, ...updates } : cond))
);
}, []);
// 조건 삭제
@ -341,26 +367,26 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 열 범위 중복 검사
const rowOverlapErrors = useMemo(() => {
const errors: { conditionIndex: number; overlappingWith: number; overlappingRows: number[] }[] = [];
for (let i = 0; i < conditions.length; i++) {
const cond1 = conditions[i];
if (cond1.startRow <= 0 || cond1.endRow < cond1.startRow) continue;
for (let j = i + 1; j < conditions.length; j++) {
const cond2 = conditions[j];
if (cond2.startRow <= 0 || cond2.endRow < cond2.startRow) continue;
// 범위 겹침 확인
const overlapStart = Math.max(cond1.startRow, cond2.startRow);
const overlapEnd = Math.min(cond1.endRow, cond2.endRow);
if (overlapStart <= overlapEnd) {
// 겹치는 열 목록
const overlappingRows: number[] = [];
for (let r = overlapStart; r <= overlapEnd; r++) {
overlappingRows.push(r);
}
errors.push({
conditionIndex: i,
overlappingWith: j,
@ -369,7 +395,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
}
}
}
return errors;
}, [conditions]);
@ -378,8 +404,12 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지)
const warehouseCodeForQuery = context.warehouseCode;
const floorForQuery = context.floor; // 라벨 값 (예: "1층")
const zoneForQuery = context.zone; // 라벨 값 (예: "A구역")
// DB 쿼리 시에는 카테고리 코드 사용 (코드로 통일)
const floorForQuery = (context as any).floorCode || context.floor;
const zoneForQuery = (context as any).zoneCode || context.zone;
// 화면 표시용 라벨
const floorLabel = context.floor;
const zoneLabel = context.zone;
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
useEffect(() => {
@ -413,19 +443,19 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 직접 apiClient 사용하여 정확한 형식으로 요청
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
// autoFilter: true로 회사별 데이터 필터링 적용
const response = await apiClient.post("/table-management/tables/warehouse_location/data", {
const response = await apiClient.post(`/table-management/tables/warehouse_location/data`, {
page: 1,
size: 1000, // 충분히 큰 값
search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치)
autoFilter: true, // 회사별 데이터 필터링 (멀티테넌시)
search: searchParams, // 백엔드가 기대하는 형식 (equals 연산자로 정확한 일치)
});
console.log("🔍 기존 위치 데이터 응답:", response.data);
// API 응답 구조: { success: true, data: { data: [...], total, ... } }
const responseData = response.data?.data || response.data;
const dataArray = Array.isArray(responseData) ? responseData : responseData?.data || [];
const dataArray = Array.isArray(responseData)
? responseData
: (responseData?.data || []);
if (dataArray.length > 0) {
const existing = dataArray.map((item: any) => ({
@ -474,7 +504,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 기존 데이터와 중복 체크
const errors: { row: number; existingLevels: number[] }[] = [];
plannedRows.forEach((levels, row) => {
const existingForRow = existingLocations.filter((loc) => parseInt(loc.row_num) === row);
const existingForRow = existingLocations.filter(
(loc) => parseInt(loc.row_num) === row
);
if (existingForRow.length > 0) {
const existingLevels = existingForRow.map((loc) => parseInt(loc.level_num));
const duplicateLevels = levels.filter((l) => existingLevels.includes(l));
@ -521,14 +553,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
},
[context],
[context]
);
// 미리보기 생성
@ -549,26 +581,20 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 열 범위 중복 검증
if (hasRowOverlap) {
const overlapInfo = rowOverlapErrors
.map((err) => {
const rows = err.overlappingRows.join(", ");
return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}${rows}`;
})
.join("\n");
const overlapInfo = rowOverlapErrors.map((err) => {
const rows = err.overlappingRows.join(", ");
return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}${rows}`;
}).join("\n");
alert(`열 범위가 중복됩니다:\n${overlapInfo}\n\n중복된 열을 수정해주세요.`);
return;
}
// 기존 데이터와 중복 검증 - duplicateErrors 직접 체크
if (duplicateErrors.length > 0) {
const duplicateInfo = duplicateErrors
.map((err) => {
return `${err.row}${err.existingLevels.join(", ")}`;
})
.join(", ");
alert(
`이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`,
);
const duplicateInfo = duplicateErrors.map((err) => {
return `${err.row}${err.existingLevels.join(", ")}`;
}).join(", ");
alert(`이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`);
return;
}
@ -580,18 +606,20 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
for (let level = 1; level <= cond.levels; level++) {
const { code, name } = generateLocationCode(row, level);
// 테이블 컬럼명과 동일하게 생성
// DB 저장 시에는 카테고리 코드 사용 (코드로 통일)
const ctxAny = context as any;
locations.push({
row_num: String(row),
level_num: String(level),
location_code: code,
location_name: name,
location_type: context?.locationType || "선반",
status: context?.status || "사용",
// 추가 필드 (테이블 컬럼명과 동일)
location_type: ctxAny?.locationTypeCode || context?.locationType || "선반",
status: ctxAny?.statusCode || context?.status || "사용",
// 추가 필드 (테이블 컬럼명과 동일) - 카테고리 코드 사용
warehouse_code: context?.warehouseCode,
warehouse_name: context?.warehouseName,
floor: context?.floor,
zone: context?.zone,
floor: ctxAny?.floorCode || context?.floor,
zone: ctxAny?.zoneCode || context?.zone,
});
}
}
@ -606,7 +634,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
setPreviewData(locations);
setIsPreviewGenerated(true);
console.log("🏗️ [RackStructure] 생성된 위치 데이터:", {
locationsCount: locations.length,
firstLocation: locations[0],
@ -617,19 +645,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
zone: context?.zone,
},
});
onChange?.(locations);
}, [
conditions,
context,
generateLocationCode,
onChange,
missingFields,
hasRowOverlap,
duplicateErrors,
existingLocations,
rowOverlapErrors,
]);
}, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]);
// 템플릿 저장
const saveTemplate = useCallback(() => {
@ -664,7 +682,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-blue-500" />
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-blue-500" />
</CardTitle>
{!readonly && (
<div className="flex items-center gap-2">
@ -705,7 +724,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<AlertDescription>
: <strong>{missingFields.join(", ")}</strong>
<br />
<span className="text-xs">( )</span>
<span className="text-xs">
( )
</span>
</AlertDescription>
</Alert>
)}
@ -719,12 +740,13 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<ul className="mt-1 list-inside list-disc text-xs">
{rowOverlapErrors.map((err, idx) => (
<li key={idx}>
{err.conditionIndex + 1} {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}
{err.conditionIndex + 1} {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}
</li>
))}
</ul>
<span className="mt-1 block text-xs"> .</span>
<span className="mt-1 block text-xs">
.
</span>
</AlertDescription>
</Alert>
)}
@ -742,7 +764,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</li>
))}
</ul>
<span className="mt-1 block text-xs"> / .</span>
<span className="mt-1 block text-xs">
/ .
</span>
</AlertDescription>
</Alert>
)}
@ -751,7 +775,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{isCheckingDuplicates && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4 animate-spin" />
<AlertDescription> ...</AlertDescription>
<AlertDescription>
...
</AlertDescription>
</Alert>
)}
@ -775,10 +801,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</span>
)}
{context.floor && (
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800">: {context.floor}</span>
<span className="rounded bg-green-100 px-2 py-1 text-xs text-green-800">
: {context.floor}
</span>
)}
{context.zone && (
<span className="rounded bg-purple-100 px-2 py-1 text-xs text-purple-800">: {context.zone}</span>
<span className="rounded bg-purple-100 px-2 py-1 text-xs text-purple-800">
: {context.zone}
</span>
)}
{context.locationType && (
<span className="rounded bg-orange-100 px-2 py-1 text-xs text-orange-800">
@ -786,7 +816,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</span>
)}
{context.status && (
<span className="rounded bg-gray-200 px-2 py-1 text-xs text-gray-800">: {context.status}</span>
<span className="rounded bg-gray-200 px-2 py-1 text-xs text-gray-800">
: {context.status}
</span>
)}
</div>
)}
@ -822,7 +854,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<p className="mb-4 text-gray-500"> </p>
{!readonly && (
<Button onClick={addCondition} className="gap-1">
<Plus className="h-4 w-4" />
<Plus className="h-4 w-4" />
</Button>
)}
</div>
@ -908,11 +941,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell className="font-mono">{loc.location_code}</TableCell>
<TableCell>{loc.location_name}</TableCell>
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
<TableCell className="text-center">{loc.row_num.padStart(2, "0")}</TableCell>
{/* 미리보기에서는 카테고리 코드를 라벨로 변환하여 표시 */}
<TableCell className="text-center">{getCategoryLabel(loc.floor) || context?.floor || "1"}</TableCell>
<TableCell className="text-center">{getCategoryLabel(loc.zone) || context?.zone || "A"}</TableCell>
<TableCell className="text-center">
{loc.row_num.padStart(2, "0")}
</TableCell>
<TableCell className="text-center">{loc.level_num}</TableCell>
<TableCell className="text-center">{loc.location_type}</TableCell>
<TableCell className="text-center">{getCategoryLabel(loc.location_type) || loc.location_type}</TableCell>
<TableCell className="text-center">-</TableCell>
</TableRow>
))}
@ -934,7 +970,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{isSaveMode ? "템플릿 저장" : "템플릿 관리"}</DialogTitle>
<DialogTitle>
{isSaveMode ? "템플릿 저장" : "템플릿 관리"}
</DialogTitle>
</DialogHeader>
{isSaveMode ? (
@ -960,7 +998,11 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<div className="space-y-4">
{/* 저장 버튼 */}
{conditions.length > 0 && (
<Button variant="outline" className="w-full gap-2" onClick={() => setIsSaveMode(true)}>
<Button
variant="outline"
className="w-full gap-2"
onClick={() => setIsSaveMode(true)}
>
<Save className="h-4 w-4" />
릿
</Button>
@ -978,13 +1020,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
>
<div>
<div className="font-medium">{template.name}</div>
<div className="text-xs text-gray-500">{template.conditions.length} </div>
<div className="text-xs text-gray-500">
{template.conditions.length}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
<Button
variant="outline"
size="sm"
onClick={() => loadTemplate(template)}
>
</Button>
<Button variant="ghost" size="sm" onClick={() => deleteTemplate(template.id)}>
<Button
variant="ghost"
size="sm"
onClick={() => deleteTemplate(template.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
@ -993,7 +1045,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="py-8 text-center text-gray-500"> 릿 </div>
<div className="py-8 text-center text-gray-500">
릿
</div>
)}
</div>
)}
@ -1011,3 +1065,5 @@ export const RackStructureWrapper: React.FC<RackStructureComponentProps> = (prop
</div>
);
};

View File

@ -1,422 +0,0 @@
"use client";
import React, { useMemo, useState } from "react";
import { ChevronDown, ChevronUp, Loader2, AlertCircle, Check, Package, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { SubDataLookupConfig } from "@/types/repeater";
import { useSubDataLookup } from "./useSubDataLookup";
export interface SubDataLookupPanelProps {
config: SubDataLookupConfig;
linkValue: string | number | null; // 상위 항목의 연결 값 (예: item_code)
itemIndex: number; // 상위 항목 인덱스
onSelectionChange: (selectedItem: any | null, maxValue: number | null) => void;
disabled?: boolean;
className?: string;
}
/**
*
* /
*/
export const SubDataLookupPanel: React.FC<SubDataLookupPanelProps> = ({
config,
linkValue,
itemIndex,
onSelectionChange,
disabled = false,
className,
}) => {
const {
data,
isLoading,
error,
selectedItem,
setSelectedItem,
isInputEnabled,
maxValue,
isExpanded,
setIsExpanded,
refetch,
getSelectionSummary,
} = useSubDataLookup({
config,
linkValue,
itemIndex,
enabled: !disabled,
});
// 선택 핸들러
const handleSelect = (item: any) => {
if (disabled) return;
// 이미 선택된 항목이면 선택 해제
const newSelectedItem = selectedItem?.id === item.id ? null : item;
setSelectedItem(newSelectedItem);
// 최대값 계산
let newMaxValue: number | null = null;
if (newSelectedItem && config.conditionalInput.maxValueField) {
const val = newSelectedItem[config.conditionalInput.maxValueField];
newMaxValue = typeof val === "number" ? val : parseFloat(val) || null;
}
onSelectionChange(newSelectedItem, newMaxValue);
};
// 컬럼 라벨 가져오기
const getColumnLabel = (columnName: string): string => {
return config.lookup.columnLabels?.[columnName] || columnName;
};
// 표시할 컬럼 목록
const displayColumns = config.lookup.displayColumns || [];
// 요약 정보 표시용 선택 상태
const summaryText = useMemo(() => {
if (!selectedItem) return null;
return getSelectionSummary();
}, [selectedItem, getSelectionSummary]);
// linkValue가 없으면 렌더링하지 않음
if (!linkValue) {
return null;
}
// 인라인 모드 렌더링
if (config.ui?.expandMode === "inline" || !config.ui?.expandMode) {
return (
<div className={cn("w-full", className)}>
{/* 토글 버튼 및 요약 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) {
refetch(); // 펼칠 때 데이터 재조회
}
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{/* 선택 요약 표시 */}
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
{/* 확장된 패널 */}
{isExpanded && (
<div
className="mt-2 rounded-md border bg-gray-50"
style={{ maxHeight: config.ui?.maxHeight || "150px", overflowY: "auto" }}
>
{/* 에러 상태 */}
{error && (
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
</Button>
</div>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span> ...</span>
</div>
)}
{/* 데이터 없음 */}
{!isLoading && !error && data.length === 0 && (
<div className="p-4 text-center text-xs text-gray-500">
{config.ui?.emptyMessage || "재고 데이터가 없습니다"}
</div>
)}
{/* 데이터 테이블 */}
{!isLoading && !error && data.length > 0 && (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="w-8 p-2 text-center"></th>
{displayColumns.map((col) => (
<th key={col} className="p-2 text-left font-medium">
{getColumnLabel(col)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, idx) => {
const isSelected = selectedItem?.id === item.id;
return (
<tr
key={item.id || idx}
onClick={() => handleSelect(item)}
className={cn(
"cursor-pointer border-t transition-colors",
isSelected ? "bg-blue-50" : "hover:bg-gray-100",
disabled && "cursor-not-allowed opacity-50",
)}
>
<td className="p-2 text-center">
<div
className={cn(
"mx-auto flex h-4 w-4 items-center justify-center rounded-full border",
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
</td>
{displayColumns.map((col) => (
<td key={col} className="p-2">
{item[col] ?? "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
{/* 필수 선택 안내 */}
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
<p className="mt-1 text-[10px] text-amber-600">
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}()
</p>
)}
</div>
);
}
// 모달 모드 렌더링
if (config.ui?.expandMode === "modal") {
return (
<div className={cn("w-full", className)}>
{/* 재고 조회 버튼 및 요약 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsExpanded(true);
refetch(); // 모달 열 때 데이터 재조회
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Search className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{/* 선택 요약 표시 */}
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
{/* 필수 선택 안내 */}
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
<p className="mt-1 text-[10px] text-amber-600">
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}()
</p>
)}
{/* 모달 */}
<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. / .
</DialogDescription>
</DialogHeader>
<div
className="rounded-md border"
style={{ maxHeight: config.ui?.maxHeight || "300px", overflowY: "auto" }}
>
{/* 에러 상태 */}
{error && (
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
</Button>
</div>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center gap-2 p-8 text-sm text-gray-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span> ...</span>
</div>
)}
{/* 데이터 없음 */}
{!isLoading && !error && data.length === 0 && (
<div className="p-8 text-center text-sm text-gray-500">
{config.ui?.emptyMessage || "해당 품목의 재고가 없습니다"}
</div>
)}
{/* 데이터 테이블 */}
{!isLoading && !error && data.length > 0 && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="w-12 p-3 text-center"></th>
{displayColumns.map((col) => (
<th key={col} className="p-3 text-left font-medium">
{getColumnLabel(col)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, idx) => {
const isSelected = selectedItem?.id === item.id;
return (
<tr
key={item.id || idx}
onClick={() => handleSelect(item)}
className={cn(
"cursor-pointer border-t transition-colors",
isSelected ? "bg-blue-50" : "hover:bg-gray-50",
disabled && "cursor-not-allowed opacity-50",
)}
>
<td className="p-3 text-center">
<div
className={cn(
"mx-auto flex h-5 w-5 items-center justify-center rounded-full border-2",
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
</td>
{displayColumns.map((col) => (
<td key={col} className="p-3">
{item[col] ?? "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsExpanded(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={() => setIsExpanded(false)}
disabled={!selectedItem}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// 기본값: inline 모드로 폴백 (설정이 없거나 알 수 없는 모드인 경우)
return (
<div className={cn("w-full", className)}>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) {
refetch(); // 펼칠 때 데이터 재조회
}
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
</div>
);
};
SubDataLookupPanel.displayName = "SubDataLookupPanel";

View File

@ -1,227 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { SubDataLookupConfig, SubDataState } from "@/types/repeater";
const LOG_PREFIX = {
INFO: "[SubDataLookup]",
DEBUG: "[SubDataLookup]",
WARN: "[SubDataLookup]",
ERROR: "[SubDataLookup]",
};
export interface UseSubDataLookupProps {
config: SubDataLookupConfig;
linkValue: string | number | null; // 상위 항목의 연결 값 (예: item_code)
itemIndex: number; // 상위 항목 인덱스
enabled?: boolean; // 기능 활성화 여부
}
export interface UseSubDataLookupReturn {
data: any[]; // 조회된 하위 데이터
isLoading: boolean; // 로딩 상태
error: string | null; // 에러 메시지
selectedItem: any | null; // 선택된 하위 항목
setSelectedItem: (item: any | null) => void; // 선택 항목 설정
isInputEnabled: boolean; // 조건부 입력 활성화 여부
maxValue: number | null; // 최대 입력 가능 값
isExpanded: boolean; // 확장 상태
setIsExpanded: (expanded: boolean) => void; // 확장 상태 설정
refetch: () => void; // 데이터 재조회
getSelectionSummary: () => string; // 선택 요약 텍스트
}
/**
*
* /
*/
export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookupReturn {
const { config, linkValue, itemIndex, enabled = true } = props;
// 상태
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<any | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// 이전 linkValue 추적 (중복 호출 방지)
const prevLinkValueRef = useRef<string | number | null>(null);
// 데이터 조회 함수
const fetchData = useCallback(async () => {
// 비활성화 또는 linkValue 없으면 스킵
if (!enabled || !config?.enabled || !linkValue) {
console.log(`${LOG_PREFIX.DEBUG} 조회 스킵:`, {
enabled,
configEnabled: config?.enabled,
linkValue,
itemIndex,
});
setData([]);
setSelectedItem(null);
return;
}
const { tableName, linkColumn, additionalFilters } = config.lookup;
if (!tableName || !linkColumn) {
console.warn(`${LOG_PREFIX.WARN} 필수 설정 누락:`, { tableName, linkColumn });
return;
}
console.log(`${LOG_PREFIX.INFO} 하위 데이터 조회 시작:`, {
tableName,
linkColumn,
linkValue,
itemIndex,
});
setIsLoading(true);
setError(null);
try {
// 검색 조건 구성 - 정확한 값 매칭을 위해 equals 연산자 사용
const searchCondition: Record<string, any> = {
[linkColumn]: { value: linkValue, operator: "equals" },
...additionalFilters,
};
console.log(`${LOG_PREFIX.DEBUG} API 요청 조건:`, {
tableName,
linkColumn,
linkValue,
searchCondition,
});
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 100,
search: searchCondition,
autoFilter: { enabled: true },
});
if (response.data?.success) {
const items = response.data?.data?.data || response.data?.data || [];
console.log(`${LOG_PREFIX.DEBUG} API 응답:`, {
dataCount: items.length,
firstItem: items[0],
tableName,
});
setData(items);
} else {
console.warn(`${LOG_PREFIX.WARN} API 응답 실패:`, response.data);
setData([]);
setError("데이터 조회에 실패했습니다");
}
} catch (err: any) {
console.error(`${LOG_PREFIX.ERROR} 하위 데이터 조회 실패:`, {
error: err.message,
config,
linkValue,
});
setError(err.message || "데이터 조회 중 오류가 발생했습니다");
setData([]);
} finally {
setIsLoading(false);
}
}, [enabled, config, linkValue, itemIndex]);
// linkValue 변경 시 데이터 조회
useEffect(() => {
// 같은 값이면 스킵
if (prevLinkValueRef.current === linkValue) {
return;
}
prevLinkValueRef.current = linkValue;
// linkValue가 없으면 초기화
if (!linkValue) {
setData([]);
setSelectedItem(null);
setIsExpanded(false);
return;
}
fetchData();
}, [linkValue, fetchData]);
// 조건부 입력 활성화 여부 계산
const isInputEnabled = useCallback((): boolean => {
if (!config?.enabled || !selectedItem) {
return false;
}
const { requiredFields, requiredMode = "all" } = config.selection;
if (!requiredFields || requiredFields.length === 0) {
// 필수 필드가 없으면 선택만 하면 활성화
return true;
}
// 선택된 항목에서 필수 필드 값 확인
if (requiredMode === "any") {
// 하나라도 있으면 OK
return requiredFields.some((field) => {
const value = selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
} else {
// 모두 있어야 OK
return requiredFields.every((field) => {
const value = selectedItem[field];
return value !== undefined && value !== null && value !== "";
});
}
}, [config, selectedItem]);
// 최대값 계산
const getMaxValue = useCallback((): number | null => {
if (!config?.enabled || !selectedItem) {
return null;
}
const { maxValueField } = config.conditionalInput;
if (!maxValueField) {
return null;
}
const maxValue = selectedItem[maxValueField];
return typeof maxValue === "number" ? maxValue : parseFloat(maxValue) || null;
}, [config, selectedItem]);
// 선택 요약 텍스트 생성
const getSelectionSummary = useCallback((): string => {
if (!selectedItem) {
return "선택 안됨";
}
const { displayColumns, columnLabels } = config.lookup;
const parts: string[] = [];
displayColumns.forEach((col) => {
const value = selectedItem[col];
if (value !== undefined && value !== null && value !== "") {
const label = columnLabels?.[col] || col;
parts.push(`${label}: ${value}`);
}
});
return parts.length > 0 ? parts.join(", ") : "선택됨";
}, [selectedItem, config?.lookup]);
return {
data,
isLoading,
error,
selectedItem,
setSelectedItem,
isInputEnabled: isInputEnabled(),
maxValue: getMaxValue(),
isExpanded,
setIsExpanded,
refetch: fetchData,
getSelectionSummary,
};
}

View File

@ -11,13 +11,9 @@ export interface AdditionalTabConfig {
// 탭 고유 정보
tabId: string;
label: string;
langKeyId?: number; // 탭 라벨 다국어 키 ID
langKey?: string; // 탭 라벨 다국어 키
// === 우측 패널과 동일한 설정 ===
title: string;
titleLangKeyId?: number; // 탭 제목 다국어 키 ID
titleLangKey?: string; // 탭 제목 다국어 키
panelHeaderHeight?: number;
tableName?: string;
dataSource?: string;
@ -111,8 +107,6 @@ export interface SplitPanelLayoutConfig {
// 좌측 패널 설정
leftPanel: {
title: string;
langKeyId?: number; // 다국어 키 ID
langKey?: string; // 다국어 키
panelHeaderHeight?: number; // 패널 상단 헤더 높이 (px)
tableName?: string; // 데이터베이스 테이블명
dataSource?: string; // API 엔드포인트
@ -176,8 +170,6 @@ export interface SplitPanelLayoutConfig {
// 우측 패널 설정
rightPanel: {
title: string;
langKeyId?: number; // 다국어 키 ID
langKey?: string; // 다국어 키
panelHeaderHeight?: number; // 패널 상단 헤더 높이 (px)
tableName?: string;
dataSource?: string;

View File

@ -6,7 +6,6 @@ import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ColumnConfig } from "./types";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface SingleTableWithStickyProps {
visibleColumns?: ColumnConfig[];
@ -75,298 +74,281 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
currentSearchIndex = 0,
searchTerm = "",
}) => {
const { getTranslatedText } = useScreenMultiLang();
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
return (
<div
className="bg-background relative flex flex-col shadow-sm"
className="relative flex flex-col bg-background shadow-sm"
style={{
width: "100%",
boxSizing: "border-box",
}}
>
<div className="relative overflow-x-auto">
<Table
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
<Table
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
>
<TableHeader
className={cn(
"border-b bg-background",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm"
)}
>
<TableHeader
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
return (
<TableHead
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "bg-background h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3"
: "text-foreground hover:text-foreground bg-background h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:h-12 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{/* langKey가 있으면 다국어 번역 사용, 없으면 기존 라벨 */}
{(column as any).langKey
? getTranslatedText(
(column as any).langKey,
columnLabels[column.columnName] || column.displayName || column.columnName,
)
: columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-muted-foreground text-sm font-medium"> </span>
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
</span>
</div>
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`row-${index}`}
return (
<TableHead
key={column.columnName}
className={cn(
"bg-background h-14 cursor-pointer border-b transition-colors sm:h-16",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
column.columnName === "__checkbox__"
? "h-10 border-0 px-3 py-2 text-center align-middle sm:h-12 sm:px-6 sm:py-3 bg-background"
: "h-10 cursor-pointer border-0 px-3 py-2 text-left align-middle font-semibold whitespace-nowrap text-xs text-foreground transition-all duration-200 select-none hover:text-foreground sm:h-12 sm:px-6 sm:py-3 sm:text-sm bg-background",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" &&
"sticky z-40 border-r border-border bg-background shadow-sm",
column.fixed === "right" &&
"sticky z-40 border-l border-border bg-background shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
onClick={() => handleRowClick(row)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns
.slice(rightFixedIndex + 1)
.reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted =
column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark
className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 font-semibold text-white"
: "bg-yellow-200 text-yellow-900",
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="ml-1 flex h-4 w-4 items-center justify-center rounded-md bg-background/50 shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="h-2.5 w-2.5 text-primary sm:h-3.5 sm:w-3.5" />
)}
>
{match}
</mark>
{after}
</>
);
};
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<svg className="h-6 w-6 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
</span>
</div>
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`row-${index}`}
className={cn(
"h-14 cursor-pointer border-b transition-colors bg-background sm:h-16",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
)}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = visibleColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = visibleColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted = column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult = isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-14 px-3 py-2 align-middle text-xs whitespace-nowrap transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
<>
{before}
<mark className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 text-white font-semibold"
: "bg-yellow-200 text-yellow-900"
)}>
{match}
</mark>
{after}
</>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
};
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"h-14 px-3 py-2 align-middle text-xs whitespace-nowrap text-foreground transition-colors sm:h-16 sm:px-6 sm:py-3 sm:text-sm",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
"sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm",
column.fixed === "right" &&
"sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);

View File

@ -6,6 +6,7 @@ import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
@ -41,7 +42,7 @@ import {
Lock,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon, Search } from "lucide-react";
import { FileText, ChevronRightIcon } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@ -211,8 +212,6 @@ export interface TableListComponentProps {
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
// 🆕 프리뷰용 회사 코드 (DynamicComponentRenderer에서 전달, 최고 관리자만 오버라이드 가능)
companyCode?: string;
}
// ========================================
@ -240,7 +239,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
screenId,
parentTabId,
parentTabsComponentId,
companyCode,
}) => {
// ========================================
// 설정 및 스타일
@ -455,7 +453,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [headerLikeFilters, setHeaderLikeFilters] = useState<Record<string, string>>({}); // LIKE 검색용
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
@ -475,6 +472,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
// 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
@ -484,23 +482,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
});
});
}
// 정확히 일치하는 경우
if (values.has(cellStr)) return true;
// 2-1. 🆕 LIKE 검색 필터 적용
if (Object.keys(headerLikeFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerLikeFilters).every(([columnName, searchText]) => {
if (!searchText || searchText.trim() === "") return true;
// 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true
if (cellStr.includes(",")) {
const cellValues = cellStr.split(",").map(v => v.trim());
return cellValues.some(v => values.has(v));
}
// 여러 가능한 컬럼명 시도
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue).toLowerCase() : "";
// LIKE 검색 (대소문자 무시)
return cellStr.includes(searchText.toLowerCase());
return false;
});
});
}
@ -558,7 +549,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, headerLikeFilters, filterGroups]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -1048,14 +1039,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 틀고정 컬럼 관련
frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number) => {
onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
const visibleCols = columnsToRegister
// updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용
const colsToUse = updatedColumns || columnsToRegister;
const visibleCols = colsToUse
.filter((col) => col.visible !== false)
.map((col) => col.columnName || col.field);
.map((col) => col.columnName || (col as any).field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
},
@ -1800,7 +1793,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만)
});
// 실제 데이터의 item_number만 추출하여 중복 확인
@ -1871,8 +1863,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 RelatedDataButtons 필터 추가
relatedButtonFilter,
isRelatedButtonTarget,
// 🆕 프리뷰용 회사 코드 오버라이드
companyCode,
]);
const fetchTableDataDebounced = useCallback(
@ -2066,7 +2056,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
@ -2109,6 +2099,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked && selectedRowsData.length > 0) {
// 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData)
const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1];
splitPanelContext.setSelectedLeftData(dataToStore);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", {
rowKey,
dataToStore,
});
} else if (!checked && selectedRowsData.length === 0) {
// 모든 선택이 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화");
} else if (selectedRowsData.length > 0) {
// 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트
splitPanelContext.setSelectedLeftData(selectedRowsData[0]);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", {
remainingCount: selectedRowsData.length,
firstData: selectedRowsData[0],
});
}
}
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
};
@ -2178,35 +2193,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
// 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
}
}
// handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨
handleRowSelection(rowKey, !isCurrentlySelected, row);
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
@ -2273,30 +2261,176 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {});
// 🆕 각 컬럼의 고유값 목록 계산
// 🆕 카테고리 라벨 매핑 (API에서 가져온 것)
const [categoryLabelCache, setCategoryLabelCache] = useState<Record<string, string>>({});
// 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
const result: Record<string, Array<{ value: string; label: string }>> = {};
if (data.length === 0) return result;
// 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용)
const globalLabelMap: Record<string, Map<string, string>> = {};
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
if (column.columnName === "__checkbox__") return;
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const values = new Set<string>();
// 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음)
const labelColumnCandidates = [
`${column.columnName}_name`, // 예: division_name
`${column.columnName}_label`, // 예: division_label
`${column.columnName}_value_label`, // 예: division_value_label
];
const valuesMap = new Map<string, string>(); // value -> label
const singleValueLabelMap = new Map<string, string>(); // 개별 값 -> 라벨 (다중값 처리용)
// 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두)
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
values.add(String(val));
const valueStr = String(val);
// 라벨 컬럼에서 라벨 찾기
let labelStr = "";
for (const labelCol of labelColumnCandidates) {
if (row[labelCol] && row[labelCol] !== "") {
labelStr = String(row[labelCol]);
break;
}
}
// 단일 값인 경우
if (!valueStr.includes(",")) {
if (labelStr) {
singleValueLabelMap.set(valueStr, labelStr);
}
} else {
// 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑
const individualValues = valueStr.split(",").map(v => v.trim());
const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : [];
// 값과 라벨 개수가 같으면 1:1 매핑
if (individualValues.length === individualLabels.length) {
individualValues.forEach((v, idx) => {
if (individualLabels[idx] && !singleValueLabelMap.has(v)) {
singleValueLabelMap.set(v, individualLabels[idx]);
}
});
}
}
}
});
result[column.columnName] = Array.from(values).sort();
// 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
// 콤마로 구분된 다중 값인지 확인
if (valueStr.includes(",")) {
// 다중 값: 각각 분리해서 개별 라벨 찾기
const individualValues = valueStr.split(",").map(v => v.trim());
// 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기
const individualLabels = individualValues.map(v =>
singleValueLabelMap.get(v) || categoryLabelCache[v] || v
);
valuesMap.set(valueStr, individualLabels.join(", "));
} else {
// 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용
const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr;
valuesMap.set(valueStr, label);
}
}
});
globalLabelMap[column.columnName] = singleValueLabelMap;
// value-label 쌍으로 저장하고 라벨 기준 정렬
result[column.columnName] = Array.from(valuesMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
});
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
}, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]);
// 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회
useEffect(() => {
const unlabeledCodes = new Set<string>();
// columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기
Object.values(columnUniqueValues).forEach(items => {
items.forEach(item => {
// 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것
if (item.label.includes("CATEGORY_")) {
// 콤마로 분리해서 개별 코드 추출
const codes = item.label.split(",").map(c => c.trim());
codes.forEach(code => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
unlabeledCodes.add(code);
}
});
}
});
});
if (unlabeledCodes.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes));
if (response.success && response.data) {
setCategoryLabelCache(prev => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [columnUniqueValues, categoryLabelCache]);
// 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용)
useEffect(() => {
if (data.length === 0) return;
const categoryCodesToFetch = new Set<string>();
// 모든 데이터 행에서 CATEGORY_ 코드 수집
data.forEach((row) => {
Object.entries(row).forEach(([key, value]) => {
if (value && typeof value === "string") {
// 콤마로 구분된 다중 값도 처리
const codes = value.split(",").map((v) => v.trim());
codes.forEach((code) => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
categoryCodesToFetch.add(code);
}
});
}
});
});
if (categoryCodesToFetch.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch));
if (response.success && response.data && Object.keys(response.data).length > 0) {
setCategoryLabelCache((prev) => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("CATEGORY_ 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [data, categoryLabelCache]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
@ -2952,7 +3086,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
headerFilters: Object.fromEntries(
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
),
headerLikeFilters, // LIKE 검색 필터 저장
pageSize: localPageSize,
timestamp: Date.now(),
};
@ -2973,7 +3106,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount,
showGridLines,
headerFilters,
headerLikeFilters,
localPageSize,
]);
@ -3010,9 +3142,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
setHeaderFilters(filters);
}
if (state.headerLikeFilters) {
setHeaderLikeFilters(state.headerLikeFilters);
}
} catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error);
}
@ -3946,7 +4075,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (enterRow) {
const rowKey = getRowKey(enterRow, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected);
handleRowSelection(rowKey, !isCurrentlySelected, enterRow);
}
break;
case " ": // Space
@ -3956,7 +4085,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (spaceRow) {
const currentRowKey = getRowKey(spaceRow, rowIndex);
const isChecked = selectedRows.has(currentRowKey);
handleRowSelection(currentRowKey, !isChecked);
handleRowSelection(currentRowKey, !isChecked, spaceRow);
}
break;
case "F2":
@ -4170,7 +4299,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)}
aria-label={`${index + 1} 선택`}
/>
);
@ -4459,10 +4588,36 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
case "boolean":
return value ? "예" : "아니오";
default:
return String(value);
// 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// rowData에서 _label 필드 찾기
if (rowData) {
const labelFieldCandidates = [
`${column.columnName}_label`,
`${column.columnName}_name`,
`${column.columnName}_value_label`,
];
for (const labelField of labelFieldCandidates) {
if (rowData[labelField] && rowData[labelField] !== "") {
return String(rowData[labelField]);
}
}
}
// categoryMappings에서 찾기
const mapping = categoryMappings[column.columnName];
if (mapping && mapping[strValue]) {
return mapping[strValue].label;
}
// categoryLabelCache에서 찾기 (필터용 캐시)
if (categoryLabelCache[strValue]) {
return categoryLabelCache[strValue];
}
}
return strValue;
}
},
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache],
);
// ========================================
@ -4601,9 +4756,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
setColumnWidths(newWidths);
// 틀고정 컬럼 업데이트
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
// 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정)
// 기존 frozen 개수를 유지하면서, 숨겨진 컬럼을 제외한 보이는 컬럼 중 처음 N개를 틀고정
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
const visibleCols = config.columns
.filter((col) => col.visible && col.columnName !== "__checkbox__")
.map((col) => col.columnName);
// 현재 설정된 frozen 컬럼 개수 (체크박스 제외)
const currentFrozenCount = config.columns.filter(
(col) => col.frozen && col.columnName !== "__checkbox__"
).length;
// 보이는 컬럼 중 처음 currentFrozenCount개를 틀고정으로 설정
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)];
setFrozenColumns(newFrozenColumns);
setFrozenColumnCount(currentFrozenCount);
// 그리드선 표시 업데이트
setShowGridLines(config.showGridLines);
@ -5666,13 +5834,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
// 숨겨진 컬럼은 제외하고 보이는 틀고정 컬럼만 포함
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i];
const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;
@ -5759,7 +5932,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}}
className={cn(
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && "text-primary bg-primary/10",
headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10",
)}
title="필터"
>
@ -5767,7 +5940,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-2"
className="w-48 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@ -5776,52 +5949,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-xs font-medium">
: {columnLabels[column.columnName] || column.displayName}
</span>
{(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && (
{headerFilters[column.columnName]?.size > 0 && (
<button
onClick={() => {
clearHeaderFilter(column.columnName);
setHeaderLikeFilters((prev) => {
const newFilters = { ...prev };
delete newFilters[column.columnName];
return newFilters;
});
}}
onClick={() => clearHeaderFilter(column.columnName)}
className="text-destructive text-xs hover:underline"
>
</button>
)}
</div>
{/* LIKE 검색 입력 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
placeholder="검색어 입력 (포함)"
value={headerLikeFilters[column.columnName] || ""}
onChange={(e) => {
setHeaderLikeFilters((prev) => ({
...prev,
[column.columnName]: e.target.value,
}));
}}
className="border-input bg-background placeholder:text-muted-foreground h-7 w-full rounded-md border pl-7 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 구분선 */}
<div className="text-muted-foreground border-t pt-2 text-[10px]"> :</div>
<div className="max-h-40 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
<div
key={val}
key={item.value}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
<div
className={cn(
@ -5831,7 +5978,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
<span className="truncate">{item.label || "(빈 값)"}</span>
</div>
);
})}
@ -6004,13 +6151,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i];
const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@ -6157,7 +6308,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 셀 포커스 상태
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
@ -6171,11 +6327,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 검색 하이라이트 여부
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i];
const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@ -6335,13 +6490,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const summary = summaryData[column.columnName];
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i];
const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;

View File

@ -19,9 +19,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@ -44,7 +42,6 @@ import { TableSectionRenderer } from "./TableSectionRenderer";
/**
* 🔗 Select
* allowCustomInput이 true이면 Combobox
*/
interface CascadingSelectFieldProps {
fieldId: string;
@ -54,7 +51,6 @@ interface CascadingSelectFieldProps {
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
allowCustomInput?: boolean;
}
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
@ -65,20 +61,12 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
onChange,
placeholder,
disabled,
allowCustomInput = false,
}) => {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
setInputValue(value || "");
}, [value]);
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
@ -94,82 +82,9 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
const isDisabled = disabled || !parentValue || loading;
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={getPlaceholder()}
disabled={isDisabled}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
onClick={() => !isDisabled && setOpen(!open)}
disabled={isDisabled}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty>
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
return (
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
<SelectTrigger id={fieldId} className="w-full" size="default">
<SelectTrigger id={fieldId} className="w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
@ -393,14 +308,6 @@ export function UniversalFormModalComponent({
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
// - 신규 등록: formData.id가 없으므로 영향 없음
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
event.detail.formData.id = formData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
}
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
@ -427,17 +334,10 @@ export function UniversalFormModalComponent({
}
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
for (const [key, value] of Object.entries(formData)) {
// 싱글/더블 언더스코어 모두 처리
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
const normalizedKey = key.startsWith("__tableSection_")
? key.replace("__tableSection_", "_tableSection_")
: key;
event.detail.formData[normalizedKey] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}${normalizedKey}, ${value.length}개 항목`);
if (key.startsWith("_tableSection_") && Array.isArray(value)) {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
}
@ -927,19 +827,6 @@ export function UniversalFormModalComponent({
const tableSectionKey = `__tableSection_${section.id}`;
newFormData[tableSectionKey] = items;
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`);
// 🆕 원본 그룹 데이터 저장 (삭제 추적용)
// groupedDataInitializedRef가 false일 때만 설정 (true면 _groupedData useEffect에서 이미 처리됨)
// DB에서 로드한 데이터를 originalGroupedData에 저장해야 삭제 시 비교 가능
if (!groupedDataInitializedRef.current) {
setOriginalGroupedData((prev) => {
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))];
console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`);
return newOriginal;
});
} else {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: _groupedData로 이미 초기화됨, originalGroupedData 설정 스킵`);
}
}
} catch (error) {
console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error);
@ -1071,26 +958,6 @@ export function UniversalFormModalComponent({
if (fieldConfig) break;
}
// 🆕 연쇄 드롭다운: 부모 필드 변경 시 자식 필드 초기화
const childFieldsToReset: string[] = [];
for (const section of config.sections) {
if (section.type === "table" || section.repeatable) continue;
for (const field of section.fields || []) {
// field.cascading 방식 체크
if (field.cascading?.enabled && field.cascading?.parentField === columnName) {
if (field.cascading.clearOnParentChange !== false) {
childFieldsToReset.push(field.columnName);
}
}
// selectOptions.cascading 방식 체크
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField === columnName) {
if (field.selectOptions.cascading.clearOnParentChange !== false) {
childFieldsToReset.push(field.columnName);
}
}
}
}
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
@ -1109,12 +976,6 @@ export function UniversalFormModalComponent({
}
}
// 🆕 연쇄 드롭다운 자식 필드 초기화
for (const childField of childFieldsToReset) {
newData[childField] = "";
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
}
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
@ -1602,7 +1463,7 @@ export function UniversalFormModalComponent({
);
case "select": {
// 🆕 연쇄 드롭다운 처리 (기존 field.cascading 방식)
// 🆕 연쇄 드롭다운 처리
if (field.cascading?.enabled) {
const cascadingConfig = field.cascading;
const parentValue = formData[cascadingConfig.parentField];
@ -1616,39 +1477,6 @@ export function UniversalFormModalComponent({
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
// 🆕 연쇄 드롭다운 처리 (selectOptions.type === "cascading" 방식)
if (field.selectOptions?.type === "cascading" && field.selectOptions?.cascading?.parentField) {
const cascadingOpts = field.selectOptions.cascading;
const parentValue = formData[cascadingOpts.parentField];
// selectOptions 기반 cascading config를 CascadingDropdownConfig 형태로 변환
const cascadingConfig: CascadingDropdownConfig = {
enabled: true,
parentField: cascadingOpts.parentField,
sourceTable: cascadingOpts.sourceTable || field.selectOptions.tableName || "",
parentKeyColumn: cascadingOpts.parentKeyColumn || "",
valueColumn: field.selectOptions.valueColumn || "",
labelColumn: field.selectOptions.labelColumn || "",
emptyParentMessage: cascadingOpts.emptyParentMessage,
noOptionsMessage: cascadingOpts.noOptionsMessage,
clearOnParentChange: cascadingOpts.clearOnParentChange !== false,
};
return (
<CascadingSelectField
fieldId={fieldKey}
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
allowCustomInput={field.selectOptions?.allowCustomInput}
/>
);
}
@ -2358,7 +2186,6 @@ export function UniversalFormModalComponent({
}
// Select 필드 컴포넌트 (옵션 로딩 포함)
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
interface SelectFieldProps {
fieldId: string;
value: any;
@ -2372,10 +2199,6 @@ interface SelectFieldProps {
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value || "");
const allowCustomInput = optionConfig?.allowCustomInput || false;
useEffect(() => {
if (optionConfig) {
@ -2386,82 +2209,6 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
}
}, [fieldId, optionConfig, loadOptions]);
// value가 외부에서 변경되면 inputValue도 동기화
useEffect(() => {
// 선택된 값이 있으면 해당 라벨을 표시, 없으면 value 그대로 표시
const selectedOption = options.find((opt) => opt.value === value);
setInputValue(selectedOption ? selectedOption.label : value || "");
}, [value, options]);
// Combobox 형태 (직접 입력 허용)
if (allowCustomInput) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="relative w-full">
<Input
id={fieldId}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
onChange(e.target.value);
}}
placeholder={loading ? "로딩 중..." : placeholder}
disabled={disabled || loading}
className="w-full pr-8"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
onClick={() => !disabled && !loading && setOpen(!open)}
disabled={disabled || loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="h-9" />
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{options
.filter((option) => option.value && option.value !== "")
.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
setInputValue(option.label);
onChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 기본 Select 형태 (목록에서만 선택)
return (
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
<SelectTrigger size="default">

View File

@ -870,14 +870,6 @@ export function UniversalFormModalConfigPanel({
onLoadTableColumns={loadTableColumns}
targetTableName={config.saveConfig?.tableName}
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
allFieldsWithSections={config.sections
.filter(s => s.type !== "table" && !s.repeatable)
.map(s => ({
sectionId: s.id,
sectionTitle: s.title,
fields: s.fields || []
}))
}
/>
)}

View File

@ -29,7 +29,6 @@ import {
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
} from "../types";
import { apiClient } from "@/lib/api/client";
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
// 카테고리 컬럼 타입 (table_column_category_values 용)
interface CategoryColumnOption {
@ -57,13 +56,6 @@ export interface AvailableParentField {
sourceTable?: string; // 출처 테이블명
}
// 섹션별 필드 그룹
interface SectionFieldGroup {
sectionId: string;
sectionTitle: string;
fields: FormFieldConfig[];
}
interface FieldDetailSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -76,8 +68,6 @@ interface FieldDetailSettingsModalProps {
// 저장 테이블 정보 (타겟 컬럼 선택용)
targetTableName?: string;
targetTableColumns?: { name: string; type: string; label: string }[];
// 연쇄 드롭다운 부모 필드 선택용 - 모든 섹션의 필드 목록 (섹션별 그룹핑)
allFieldsWithSections?: SectionFieldGroup[];
}
export function FieldDetailSettingsModal({
@ -92,7 +82,6 @@ export function FieldDetailSettingsModal({
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
targetTableName: _targetTableName,
targetTableColumns = [],
allFieldsWithSections = [],
}: FieldDetailSettingsModalProps) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void _targetTableName; // 향후 사용 가능성을 위해 유지
@ -103,12 +92,6 @@ export function FieldDetailSettingsModal({
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
// 연쇄 관계 목록 상태
const [cascadingRelations, setCascadingRelations] = useState<CascadingRelation[]>([]);
const [loadingCascadingRelations, setLoadingCascadingRelations] = useState(false);
const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false);
const [parentFieldOpen, setParentFieldOpen] = useState(false);
// Combobox 열림 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
@ -132,16 +115,6 @@ export function FieldDetailSettingsModal({
}
}
}, [open, field.linkedFieldGroup?.sourceTable, tableColumns, onLoadTableColumns]);
// 모달이 열릴 때 Select 옵션의 참조 테이블 컬럼 자동 로드
useEffect(() => {
if (open && field.selectOptions?.tableName) {
// tableColumns에 해당 테이블 컬럼이 없으면 로드
if (!tableColumns[field.selectOptions.tableName] || tableColumns[field.selectOptions.tableName].length === 0) {
onLoadTableColumns(field.selectOptions.tableName);
}
}
}, [open, field.selectOptions?.tableName, tableColumns, onLoadTableColumns]);
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
useEffect(() => {
@ -186,66 +159,6 @@ export function FieldDetailSettingsModal({
loadAllCategoryColumns();
}, [open]);
// 연쇄 관계 목록 로드 (모달 열릴 때)
useEffect(() => {
const loadCascadingRelations = async () => {
if (!open) return;
setLoadingCascadingRelations(true);
try {
const result = await getCascadingRelations("Y"); // 활성화된 것만
if (result?.success && result?.data) {
setCascadingRelations(result.data);
} else {
setCascadingRelations([]);
}
} catch (error) {
setCascadingRelations([]);
} finally {
setLoadingCascadingRelations(false);
}
};
loadCascadingRelations();
}, [open]);
// 관계 코드 선택 시 상세 설정 자동 채움
const handleRelationCodeSelect = async (relationCode: string) => {
if (!relationCode) return;
try {
const result = await getCascadingRelationByCode(relationCode);
if (result?.success && result?.data) {
const relation = result.data as CascadingRelation;
updateField({
selectOptions: {
...localField.selectOptions,
type: "cascading",
tableName: relation.child_table,
valueColumn: relation.child_value_column,
labelColumn: relation.child_label_column,
cascading: {
...localField.selectOptions?.cascading,
relationCode: relation.relation_code,
sourceTable: relation.child_table,
parentKeyColumn: relation.child_filter_column,
emptyParentMessage: relation.empty_parent_message,
noOptionsMessage: relation.no_options_message,
clearOnParentChange: relation.clear_on_parent_change === "Y",
},
},
});
// 소스 테이블 컬럼 로드
if (relation.child_table) {
onLoadTableColumns(relation.child_table);
}
}
} catch (error) {
console.error("관계 코드 조회 실패:", error);
}
};
// 필드 업데이트 함수
const updateField = (updates: Partial<FormFieldConfig>) => {
setLocalField((prev) => ({ ...prev, ...updates }));
@ -439,28 +352,14 @@ export function FieldDetailSettingsModal({
<Label className="text-[10px]"> </Label>
<Select
value={localField.selectOptions?.type || "static"}
onValueChange={(value) => {
// 타입 변경 시 관련 설정 초기화
if (value === "cascading") {
updateField({
selectOptions: {
type: "cascading",
cascading: {
parentField: "",
clearOnParentChange: true,
},
},
});
} else {
updateField({
selectOptions: {
...localField.selectOptions,
type: value as "static" | "table" | "code",
cascading: undefined,
},
});
}
}}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
type: value as "static" | "code",
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue />
@ -473,38 +372,8 @@ export function FieldDetailSettingsModal({
))}
</SelectContent>
</Select>
<HelpText>
{localField.selectOptions?.type === "cascading"
? "연쇄 드롭다운: 부모 필드 선택에 따라 옵션이 동적으로 변경됩니다"
: "테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다."}
</HelpText>
</div>
{/* 직접 입력 허용 - 모든 Select 타입에 공통 적용 */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="flex flex-col">
<span className="text-[10px] font-medium"> </span>
<span className="text-[9px] text-muted-foreground">
+
</span>
</div>
<Switch
checked={localField.selectOptions?.allowCustomInput || false}
onCheckedChange={(checked) =>
updateField({
selectOptions: {
...localField.selectOptions,
allowCustomInput: checked,
},
})
}
/>
</div>
<HelpText>
, .
.
</HelpText>
{localField.selectOptions?.type === "table" && (
<div className="space-y-3 pt-2 border-t">
<HelpText> 참조: DB .</HelpText>
@ -715,472 +584,6 @@ export function FieldDetailSettingsModal({
</div>
</div>
)}
{localField.selectOptions?.type === "cascading" && (
<div className="space-y-3 pt-2 border-t">
<HelpText>
드롭다운: 부모 .
<br />
: 거래처
</HelpText>
{/* 부모 필드 선택 - 콤보박스 (섹션별 그룹핑) */}
<div>
<Label className="text-[10px]"> *</Label>
{allFieldsWithSections.length > 0 ? (
<Popover open={parentFieldOpen} onOpenChange={setParentFieldOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={parentFieldOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.selectOptions?.cascading?.parentField
? (() => {
// 모든 섹션에서 선택된 필드 찾기
for (const section of allFieldsWithSections) {
const selectedField = section.fields.find(
(f) => f.columnName === localField.selectOptions?.cascading?.parentField
);
if (selectedField) {
return `${selectedField.label} (${selectedField.columnName})`;
}
}
return localField.selectOptions?.cascading?.parentField;
})()
: "부모 필드 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
{allFieldsWithSections.map((section) => {
// 자기 자신 제외한 필드 목록
const availableFields = section.fields.filter(
(f) => f.columnName !== field.columnName
);
if (availableFields.length === 0) return null;
return (
<CommandGroup
key={section.sectionId}
heading={section.sectionTitle}
className="[&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-primary [&_[cmdk-group-heading]]:bg-muted/50 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1"
>
{availableFields.map((f) => (
<CommandItem
key={f.id}
value={`${section.sectionTitle} ${f.columnName} ${f.label}`}
onSelect={() => {
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
parentField: f.columnName,
},
},
});
setParentFieldOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.selectOptions?.cascading?.parentField === f.columnName
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{f.label}</span>
<span className="text-[9px] text-muted-foreground">
{f.columnName} ({f.fieldType})
</span>
</div>
</CommandItem>
))}
</CommandGroup>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={localField.selectOptions?.cascading?.parentField || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
parentField: e.target.value,
},
},
})
}
placeholder="customer_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText>
<br />
: 거래처
</HelpText>
</div>
{/* 관계 코드 선택 */}
<div>
<Label className="text-[10px]"> ()</Label>
<Popover open={cascadingRelationOpen} onOpenChange={setCascadingRelationOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={cascadingRelationOpen}
className="h-7 w-full justify-between text-xs mt-1 font-normal"
>
{localField.selectOptions?.cascading?.relationCode
? (() => {
const selectedRelation = cascadingRelations.find(
(r) => r.relation_code === localField.selectOptions?.cascading?.relationCode
);
return selectedRelation
? `${selectedRelation.relation_name} (${selectedRelation.relation_code})`
: localField.selectOptions?.cascading?.relationCode;
})()
: loadingCascadingRelations
? "로딩 중..."
: "관계 선택 (또는 직접 설정)"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0" align="start">
<Command>
<CommandInput placeholder="관계 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs text-center">
.
</CommandEmpty>
<CommandGroup>
{/* 직접 설정 옵션 */}
<CommandItem
value="__direct__"
onSelect={() => {
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
relationCode: undefined,
},
},
});
setCascadingRelationOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!localField.selectOptions?.cascading?.relationCode
? "opacity-100"
: "opacity-0"
)}
/>
<span className="text-muted-foreground"> </span>
</CommandItem>
<Separator className="my-1" />
{cascadingRelations.map((relation) => (
<CommandItem
key={relation.relation_id}
value={`${relation.relation_code} ${relation.relation_name}`}
onSelect={() => {
handleRelationCodeSelect(relation.relation_code);
setCascadingRelationOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
localField.selectOptions?.cascading?.relationCode === relation.relation_code
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{relation.relation_name}</span>
<span className="text-[9px] text-muted-foreground">
{relation.parent_table} {relation.child_table}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<HelpText>
.
<br />
.
</HelpText>
</div>
<Separator />
{/* 상세 설정 (수정 가능) */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<SettingsIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-[10px] font-medium"> ( )</span>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={localField.selectOptions?.cascading?.sourceTable || localField.selectOptions?.tableName || ""}
onValueChange={(value) => {
updateField({
selectOptions: {
...localField.selectOptions,
tableName: value,
cascading: {
...localField.selectOptions?.cascading,
sourceTable: value,
},
},
});
onLoadTableColumns(value);
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tables.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.label || t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> (: delivery_destination)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.cascading?.parentKeyColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
parentKeyColumn: value,
},
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.cascading?.parentKeyColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
parentKeyColumn: e.target.value,
},
},
})
}
placeholder="customer_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> (: customer_code)</HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.valueColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
valueColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.valueColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
valueColumn: e.target.value,
},
})
}
placeholder="destination_code"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> value로 </HelpText>
</div>
<div>
<Label className="text-[10px]"> </Label>
{selectTableColumns.length > 0 ? (
<Select
value={localField.selectOptions?.labelColumn || ""}
onValueChange={(value) =>
updateField({
selectOptions: {
...localField.selectOptions,
labelColumn: value,
},
})
}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{selectTableColumns.map((col) => (
<SelectItem key={col.name} value={col.name}>
{col.name}
{col.label !== col.name && ` (${col.label})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localField.selectOptions?.labelColumn || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
labelColumn: e.target.value,
},
})
}
placeholder="destination_name"
className="h-7 text-xs mt-1"
/>
)}
<HelpText> </HelpText>
</div>
<Separator />
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localField.selectOptions?.cascading?.emptyParentMessage || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
emptyParentMessage: e.target.value,
},
},
})
}
placeholder="상위 항목을 먼저 선택하세요"
className="h-7 text-xs mt-1"
/>
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localField.selectOptions?.cascading?.noOptionsMessage || ""}
onChange={(e) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
noOptionsMessage: e.target.value,
},
},
})
}
placeholder="선택 가능한 항목이 없습니다"
className="h-7 text-xs mt-1"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px]"> </span>
<Switch
checked={localField.selectOptions?.cascading?.clearOnParentChange !== false}
onCheckedChange={(checked) =>
updateField({
selectOptions: {
...localField.selectOptions,
cascading: {
...localField.selectOptions?.cascading,
clearOnParentChange: checked,
},
},
})
}
/>
</div>
<HelpText> </HelpText>
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@ -1516,7 +919,7 @@ export function FieldDetailSettingsModal({
preview = `${subLabel} - ${mainLabel}`;
} else if (format === "name_code" && subCol) {
preview = `${mainLabel} (${subLabel})`;
} else if (!subCol) {
} else if (format !== "name_only" && !subCol) {
preview = `${mainLabel} (서브 컬럼을 선택하세요)`;
} else {
preview = mainLabel;

View File

@ -7,7 +7,7 @@
// Select 옵션 설정
export interface SelectOptionConfig {
type?: "static" | "table" | "code" | "cascading"; // 옵션 타입 (기본: static)
type?: "static" | "table" | "code"; // 옵션 타입 (기본: static)
// 정적 옵션
staticOptions?: { value: string; label: string }[];
// 테이블 기반 옵션
@ -19,24 +19,6 @@ export interface SelectOptionConfig {
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
categoryKey?: string;
// 연쇄 드롭다운 설정 (type이 "cascading"일 때 사용)
cascading?: {
parentField?: string; // 부모 필드명 (같은 폼 내)
relationCode?: string; // 관계 코드 (cascading_relation 테이블)
// 직접 설정 또는 관계 코드에서 가져온 값 수정 시 사용
sourceTable?: string; // 옵션을 조회할 테이블
parentKeyColumn?: string; // 부모 값과 매칭할 컬럼
// valueColumn, labelColumn은 상위 속성 사용
emptyParentMessage?: string; // 부모 미선택 시 메시지
noOptionsMessage?: string; // 옵션 없음 메시지
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
};
// 직접 입력 허용 (모든 Select 타입에 공통 적용)
// true: Combobox 형태로 목록 선택 + 직접 입력 가능
// false/undefined: 기본 Select 형태로 목록에서만 선택 가능
allowCustomInput?: boolean;
}
// 채번규칙 설정
@ -891,7 +873,6 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
{ value: "static", label: "직접 입력" },
{ value: "table", label: "테이블 참조" },
{ value: "code", label: "공통코드" },
{ value: "cascading", label: "연쇄 드롭다운" },
] as const;
// 연동 필드 표시 형식 옵션

View File

@ -708,47 +708,6 @@ export class ButtonActionExecutor {
if (repeaterJsonKeys.length > 0) {
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
// 🎯 채번 규칙 할당 처리 (RepeaterFieldGroup 저장 전에 실행)
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작");
const fieldsWithNumberingRepeater: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(context.formData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumberingRepeater[fieldName] = value as string;
console.log(`🎯 [handleSave-RepeaterFieldGroup] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
}
}
console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater);
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
if (Object.keys(fieldsWithNumberingRepeater).length > 0) {
console.log("🎯 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try {
console.log(`🔄 [handleSave-RepeaterFieldGroup] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`);
context.formData[fieldName] = newCode;
} else {
console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error);
}
} catch (allocateError) {
console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError);
}
}
}
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 완료");
// 🆕 상단 폼 데이터(마스터 정보) 추출
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
const masterFields: Record<string, any> = {};
@ -2012,43 +1971,19 @@ export class ButtonActionExecutor {
}
});
// 🆕 메인 테이블 UPDATE/INSERT 판단
// - formData.id가 있으면 편집 모드 → UPDATE
// - formData.id가 없으면 신규 등록 → INSERT
const existingMainId = formData.id;
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
console.log("📦 [handleUniversalFormModalTableSectionSave] UPDATE/INSERT 판단:", {
existingMainId,
isMainUpdate,
const mainSaveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: mainRowToSave,
});
let mainSaveResult: { success: boolean; data?: any; message?: string };
if (isMainUpdate) {
// 🔄 편집 모드: UPDATE 실행
console.log("🔄 [handleUniversalFormModalTableSectionSave] 메인 테이블 UPDATE 실행, ID:", existingMainId);
mainSaveResult = await DynamicFormApi.updateFormData(existingMainId, {
tableName: tableName!,
data: mainRowToSave,
});
mainRecordId = existingMainId;
} else {
// 신규 등록: INSERT 실행
console.log(" [handleUniversalFormModalTableSectionSave] 메인 테이블 INSERT 실행");
mainSaveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: mainRowToSave,
});
mainRecordId = mainSaveResult.data?.id || null;
}
if (!mainSaveResult.success) {
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
}
mainRecordId = mainSaveResult.data?.id || null;
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
}

View File

@ -1,604 +0,0 @@
/**
*
* .
*/
import { ComponentData } from "@/types/screen";
// 추출된 라벨 타입
export interface ExtractedLabel {
id: string;
componentId: string;
label: string;
type: "label" | "title" | "button" | "placeholder" | "column" | "filter" | "field" | "tab" | "action";
parentType?: string;
parentLabel?: string;
langKeyId?: number;
langKey?: string;
}
// 입력 폼 컴포넌트인지 확인
const INPUT_COMPONENT_TYPES = new Set([
"text-field",
"number-field",
"date-field",
"datetime-field",
"select-field",
"checkbox-field",
"radio-field",
"textarea-field",
"file-field",
"email-field",
"tel-field",
"password-field",
"entity-field",
"code-field",
"category-field",
"input-field",
"widget",
]);
const isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type;
if (INPUT_COMPONENT_TYPES.has(compType)) return true;
if (compType === "widget" && comp.widgetType) return true;
if (comp.inputType || comp.webType) return true;
return false;
};
// 컬럼 라벨 맵 타입
export type ColumnLabelMap = Record<string, Record<string, string>>;
/**
*
* @param components
* @param columnLabelMap ()
* @returns
*/
export function extractMultilangLabels(
components: ComponentData[],
columnLabelMap: ColumnLabelMap = {}
): ExtractedLabel[] {
const labels: ExtractedLabel[] = [];
const addedLabels = new Set<string>();
const addLabel = (
componentId: string,
label: string,
type: ExtractedLabel["type"],
parentType?: string,
parentLabel?: string,
langKeyId?: number,
langKey?: string
) => {
const key = `${componentId}_${type}_${label}`;
if (label && label.trim() && !addedLabels.has(key)) {
addedLabels.add(key);
labels.push({
id: key,
componentId,
label: label.trim(),
type,
parentType,
parentLabel,
langKeyId,
langKey,
});
}
};
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
const anyComp = comp as any;
const config = anyComp.componentConfig;
const compType = anyComp.componentType || anyComp.type;
const compLabel = anyComp.label || anyComp.title || compType;
// 1. 기본 라벨 - 입력 폼 컴포넌트인 경우에만 추출
if (isInputComponent(anyComp)) {
if (anyComp.label && typeof anyComp.label === "string") {
addLabel(comp.id, anyComp.label, "label", parentType, parentLabel, anyComp.langKeyId, anyComp.langKey);
}
}
// 2. 제목
if (anyComp.title && typeof anyComp.title === "string") {
addLabel(comp.id, anyComp.title, "title", parentType, parentLabel);
}
// 3. 버튼 텍스트 (componentId에 _button 접미사 추가하여 라벨과 구분)
if (config?.text && typeof config.text === "string") {
addLabel(
`${comp.id}_button`,
config.text,
"button",
parentType,
parentLabel,
config.langKeyId,
config.langKey
);
}
// 4. placeholder
if (anyComp.placeholder && typeof anyComp.placeholder === "string") {
addLabel(comp.id, anyComp.placeholder, "placeholder", parentType, parentLabel);
}
// 5. 테이블 컬럼 - columnLabelMap에서 한글 라벨 조회
const tableName = config?.selectedTable || config?.tableName || config?.table || anyComp.tableName;
if (config?.columns && Array.isArray(config.columns)) {
config.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
// columnLabelMap에서 한글 라벨 조회, 없으면 displayName 사용
const colLabel = columnLabelMap[tableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_col_${index}`,
colLabel,
"column",
compType,
compLabel,
col.langKeyId,
col.langKey
);
}
});
}
// 6. 분할 패널 제목 및 컬럼 - columnLabelMap에서 한글 라벨 조회
// 6-1. 좌측 패널 제목
if (config?.leftPanel?.title && typeof config.leftPanel.title === "string") {
addLabel(
`${comp.id}_left_title`,
config.leftPanel.title,
"title",
compType,
`${compLabel} (좌측)`,
config.leftPanel.langKeyId,
config.leftPanel.langKey
);
}
// 6-2. 우측 패널 제목
if (config?.rightPanel?.title && typeof config.rightPanel.title === "string") {
addLabel(
`${comp.id}_right_title`,
config.rightPanel.title,
"title",
compType,
`${compLabel} (우측)`,
config.rightPanel.langKeyId,
config.rightPanel.langKey
);
}
// 6-3. 좌측 패널 컬럼
const leftTableName = config?.leftPanel?.selectedTable || config?.leftPanel?.tableName || tableName;
if (config?.leftPanel?.columns && Array.isArray(config.leftPanel.columns)) {
config.leftPanel.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[leftTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_left_col_${index}`,
colLabel,
"column",
compType,
`${compLabel} (좌측)`,
col.langKeyId,
col.langKey
);
}
});
}
const rightTableName = config?.rightPanel?.selectedTable || config?.rightPanel?.tableName || tableName;
if (config?.rightPanel?.columns && Array.isArray(config.rightPanel.columns)) {
config.rightPanel.columns.forEach((col: any, index: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[rightTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_right_col_${index}`,
colLabel,
"column",
compType,
`${compLabel} (우측)`,
col.langKeyId,
col.langKey
);
}
});
}
// 6-5. 추가 탭 (additionalTabs) 제목 및 컬럼 - rightPanel.additionalTabs 확인
const additionalTabs = config?.rightPanel?.additionalTabs || config?.additionalTabs;
if (additionalTabs && Array.isArray(additionalTabs)) {
additionalTabs.forEach((tab: any, tabIndex: number) => {
// 탭 라벨
if (tab.label && typeof tab.label === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_label`,
tab.label,
"tab",
compType,
compLabel,
tab.langKeyId,
tab.langKey
);
}
// 탭 제목
if (tab.title && typeof tab.title === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_title`,
tab.title,
"title",
compType,
`${compLabel} (탭: ${tab.label || tabIndex})`,
tab.titleLangKeyId,
tab.titleLangKey
);
}
// 탭 컬럼
const tabTableName = tab.tableName || tab.selectedTable || rightTableName;
if (tab.columns && Array.isArray(tab.columns)) {
tab.columns.forEach((col: any, colIndex: number) => {
const colName = col.columnName || col.field || col.name;
const colLabel = columnLabelMap[tabTableName]?.[colName] || col.displayName || col.label || colName;
if (colLabel && typeof colLabel === "string") {
addLabel(
`${comp.id}_addtab_${tabIndex}_col_${colIndex}`,
colLabel,
"column",
compType,
`${compLabel} (탭: ${tab.label || tabIndex})`,
col.langKeyId,
col.langKey
);
}
});
}
});
}
// 7. 검색 필터
if (config?.filter?.filters && Array.isArray(config.filter.filters)) {
config.filter.filters.forEach((filter: any, index: number) => {
if (filter.label && typeof filter.label === "string") {
addLabel(
`${comp.id}_filter_${index}`,
filter.label,
"filter",
compType,
compLabel,
filter.langKeyId,
filter.langKey
);
}
});
}
// 8. 폼 필드
if (config?.fields && Array.isArray(config.fields)) {
config.fields.forEach((field: any, index: number) => {
if (field.label && typeof field.label === "string") {
addLabel(
`${comp.id}_field_${index}`,
field.label,
"field",
compType,
compLabel,
field.langKeyId,
field.langKey
);
}
});
}
// 9. 탭
if (config?.tabs && Array.isArray(config.tabs)) {
config.tabs.forEach((tab: any, index: number) => {
if (tab.label && typeof tab.label === "string") {
addLabel(
`${comp.id}_tab_${index}`,
tab.label,
"tab",
compType,
compLabel,
tab.langKeyId,
tab.langKey
);
}
});
}
// 10. 액션 버튼
if (config?.actions?.actions && Array.isArray(config.actions.actions)) {
config.actions.actions.forEach((action: any, index: number) => {
if (action.label && typeof action.label === "string") {
addLabel(
`${comp.id}_action_${index}`,
action.label,
"action",
compType,
compLabel,
action.langKeyId,
action.langKey
);
}
});
}
// 자식 컴포넌트 재귀 탐색
if (anyComp.children && Array.isArray(anyComp.children)) {
anyComp.children.forEach((child: ComponentData) => {
extractFromComponent(child, compType, compLabel);
});
}
};
components.forEach((comp) => extractFromComponent(comp));
return labels;
}
/**
*
* @param components
* @returns Set
*/
export function extractTableNames(components: ComponentData[]): Set<string> {
const tableNames = new Set<string>();
const extractTableName = (comp: any) => {
const config = comp.componentConfig;
// 1. 컴포넌트 직접 tableName
if (comp.tableName) tableNames.add(comp.tableName);
// 2. componentConfig 직접 tableName
if (config?.tableName) tableNames.add(config.tableName);
// 3. 테이블 리스트 컴포넌트 - selectedTable (주요!)
if (config?.selectedTable) tableNames.add(config.selectedTable);
// 4. 테이블 리스트 컴포넌트 - table 속성
if (config?.table) tableNames.add(config.table);
// 5. 분할 패널의 leftPanel/rightPanel
if (config?.leftPanel?.tableName) tableNames.add(config.leftPanel.tableName);
if (config?.rightPanel?.tableName) tableNames.add(config.rightPanel.tableName);
if (config?.leftPanel?.table) tableNames.add(config.leftPanel.table);
if (config?.rightPanel?.table) tableNames.add(config.rightPanel.table);
if (config?.leftPanel?.selectedTable) tableNames.add(config.leftPanel.selectedTable);
if (config?.rightPanel?.selectedTable) tableNames.add(config.rightPanel.selectedTable);
// 6. 검색 필터의 tableName
if (config?.filter?.tableName) tableNames.add(config.filter.tableName);
// 7. properties 안의 tableName
if (comp.properties?.tableName) tableNames.add(comp.properties.tableName);
if (comp.properties?.selectedTable) tableNames.add(comp.properties.selectedTable);
// 자식 컴포넌트 탐색
if (comp.children && Array.isArray(comp.children)) {
comp.children.forEach(extractTableName);
}
};
components.forEach(extractTableName);
return tableNames;
}
/**
*
* @param components
* @param mappings [{componentId, keyId, langKey}]
* @returns
*/
export function applyMultilangMappings(
components: ComponentData[],
mappings: Array<{ componentId: string; keyId: number; langKey: string }>
): ComponentData[] {
// 매핑을 빠르게 찾기 위한 맵 생성
const mappingMap = new Map(mappings.map((m) => [m.componentId, m]));
const updateComponent = (comp: ComponentData): ComponentData => {
const anyComp = comp as any;
const config = anyComp.componentConfig;
let updated = { ...comp } as any;
// 기본 컴포넌트 라벨 매핑 확인
const labelMapping = mappingMap.get(comp.id);
if (labelMapping) {
updated.langKeyId = labelMapping.keyId;
updated.langKey = labelMapping.langKey;
}
// 버튼 텍스트 매핑 (componentId_button 형식으로 조회)
const buttonMapping = mappingMap.get(`${comp.id}_button`);
if (buttonMapping && config?.text) {
updated.componentConfig = {
...updated.componentConfig,
langKeyId: buttonMapping.keyId,
langKey: buttonMapping.langKey,
};
}
// 컬럼 매핑
if (config?.columns && Array.isArray(config.columns)) {
const updatedColumns = config.columns.map((col: any, index: number) => {
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
if (colMapping) {
return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey };
}
return col;
});
updated.componentConfig = { ...config, columns: updatedColumns };
}
// 분할 패널 좌측 제목 매핑
const leftTitleMapping = mappingMap.get(`${comp.id}_left_title`);
if (leftTitleMapping && config?.leftPanel?.title) {
updated.componentConfig = {
...updated.componentConfig,
leftPanel: {
...updated.componentConfig?.leftPanel,
langKeyId: leftTitleMapping.keyId,
langKey: leftTitleMapping.langKey,
},
};
}
// 분할 패널 우측 제목 매핑
const rightTitleMapping = mappingMap.get(`${comp.id}_right_title`);
if (rightTitleMapping && config?.rightPanel?.title) {
updated.componentConfig = {
...updated.componentConfig,
rightPanel: {
...updated.componentConfig?.rightPanel,
langKeyId: rightTitleMapping.keyId,
langKey: rightTitleMapping.langKey,
},
};
}
// 분할 패널 좌측 컬럼 매핑 (이미 업데이트된 leftPanel 사용)
const currentLeftPanel = updated.componentConfig?.leftPanel || config?.leftPanel;
if (currentLeftPanel?.columns && Array.isArray(currentLeftPanel.columns)) {
const updatedLeftColumns = currentLeftPanel.columns.map((col: any, index: number) => {
const colMapping = mappingMap.get(`${comp.id}_left_col_${index}`);
if (colMapping) {
return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey };
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
leftPanel: { ...currentLeftPanel, columns: updatedLeftColumns },
};
}
// 분할 패널 우측 컬럼 매핑 (이미 업데이트된 rightPanel 사용)
const currentRightPanel = updated.componentConfig?.rightPanel || config?.rightPanel;
if (currentRightPanel?.columns && Array.isArray(currentRightPanel.columns)) {
const updatedRightColumns = currentRightPanel.columns.map((col: any, index: number) => {
const colMapping = mappingMap.get(`${comp.id}_right_col_${index}`);
if (colMapping) {
return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey };
}
return col;
});
updated.componentConfig = {
...updated.componentConfig,
rightPanel: { ...currentRightPanel, columns: updatedRightColumns },
};
}
// 필터 매핑
if (config?.filter?.filters && Array.isArray(config.filter.filters)) {
const updatedFilters = config.filter.filters.map((filter: any, index: number) => {
const filterMapping = mappingMap.get(`${comp.id}_filter_${index}`);
if (filterMapping) {
return { ...filter, langKeyId: filterMapping.keyId, langKey: filterMapping.langKey };
}
return filter;
});
updated.componentConfig = {
...updated.componentConfig,
filter: { ...config.filter, filters: updatedFilters },
};
}
// 폼 필드 매핑
if (config?.fields && Array.isArray(config.fields)) {
const updatedFields = config.fields.map((field: any, index: number) => {
const fieldMapping = mappingMap.get(`${comp.id}_field_${index}`);
if (fieldMapping) {
return { ...field, langKeyId: fieldMapping.keyId, langKey: fieldMapping.langKey };
}
return field;
});
updated.componentConfig = { ...updated.componentConfig, fields: updatedFields };
}
// 탭 매핑
if (config?.tabs && Array.isArray(config.tabs)) {
const updatedTabs = config.tabs.map((tab: any, index: number) => {
const tabMapping = mappingMap.get(`${comp.id}_tab_${index}`);
if (tabMapping) {
return { ...tab, langKeyId: tabMapping.keyId, langKey: tabMapping.langKey };
}
return tab;
});
updated.componentConfig = { ...updated.componentConfig, tabs: updatedTabs };
}
// 추가 탭 (additionalTabs) 매핑 - rightPanel.additionalTabs 또는 additionalTabs 확인
const currentRightPanelForAddTabs = updated.componentConfig?.rightPanel || config?.rightPanel;
const configAdditionalTabs = currentRightPanelForAddTabs?.additionalTabs || config?.additionalTabs;
if (configAdditionalTabs && Array.isArray(configAdditionalTabs)) {
const updatedAdditionalTabs = configAdditionalTabs.map((tab: any, tabIndex: number) => {
let updatedTab = { ...tab };
// 탭 라벨 매핑
const labelMapping = mappingMap.get(`${comp.id}_addtab_${tabIndex}_label`);
if (labelMapping) {
updatedTab.langKeyId = labelMapping.keyId;
updatedTab.langKey = labelMapping.langKey;
}
// 탭 제목 매핑
const titleMapping = mappingMap.get(`${comp.id}_addtab_${tabIndex}_title`);
if (titleMapping) {
updatedTab.titleLangKeyId = titleMapping.keyId;
updatedTab.titleLangKey = titleMapping.langKey;
}
// 탭 컬럼 매핑
if (tab.columns && Array.isArray(tab.columns)) {
updatedTab.columns = tab.columns.map((col: any, colIndex: number) => {
const colMapping = mappingMap.get(`${comp.id}_addtab_${tabIndex}_col_${colIndex}`);
if (colMapping) {
return { ...col, langKeyId: colMapping.keyId, langKey: colMapping.langKey };
}
return col;
});
}
return updatedTab;
});
// rightPanel.additionalTabs에 저장하거나 additionalTabs에 저장
if (currentRightPanelForAddTabs?.additionalTabs) {
updated.componentConfig = {
...updated.componentConfig,
rightPanel: { ...currentRightPanelForAddTabs, additionalTabs: updatedAdditionalTabs },
};
} else {
updated.componentConfig = { ...updated.componentConfig, additionalTabs: updatedAdditionalTabs };
}
}
// 액션 버튼 매핑
if (config?.actions?.actions && Array.isArray(config.actions.actions)) {
const updatedActions = config.actions.actions.map((action: any, index: number) => {
const actionMapping = mappingMap.get(`${comp.id}_action_${index}`);
if (actionMapping) {
return { ...action, langKeyId: actionMapping.keyId, langKey: actionMapping.langKey };
}
return action;
});
updated.componentConfig = {
...updated.componentConfig,
actions: { ...config.actions, actions: updatedActions },
};
}
// 자식 컴포넌트 재귀 처리
if (anyComp.children && Array.isArray(anyComp.children)) {
updated.children = anyComp.children.map((child: ComponentData) => updateComponent(child));
}
return updated;
};
return components.map(updateComponent);
}

View File

@ -17,7 +17,6 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",
@ -78,12 +77,10 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-is": "^18.3.1",
"react-leaflet": "^5.0.0",
"react-resizable-panels": "^3.0.6",
"react-webcam": "^7.2.0",
"react-window": "^2.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",
@ -259,7 +256,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -301,7 +297,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -335,7 +330,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -1716,34 +1710,6 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
@ -2666,7 +2632,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3320,7 +3285,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3388,7 +3352,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -3702,7 +3665,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6203,7 +6165,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6214,7 +6175,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6248,7 +6208,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6331,7 +6290,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -6964,7 +6922,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8115,8 +8072,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
@ -8438,7 +8394,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9198,7 +9153,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9287,7 +9241,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9389,7 +9342,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10540,7 +10492,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11322,8 +11273,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@ -12622,7 +12572,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -12918,7 +12867,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -12948,7 +12896,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -12997,7 +12944,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13124,7 +13070,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13194,7 +13139,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13213,7 +13157,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13243,10 +13186,11 @@
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -13391,20 +13335,6 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-zoom-pan-pinch": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
"integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==",
"license": "MIT",
"engines": {
"node": ">=8",
"npm": ">=5"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
@ -13483,9 +13413,9 @@
}
},
"node_modules/recharts": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz",
"integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==",
"license": "MIT",
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
@ -13540,7 +13470,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -13563,8 +13492,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -14588,8 +14516,7 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -14677,7 +14604,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15026,7 +14952,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -25,7 +25,6 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",
@ -86,12 +85,10 @@
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0",
"react-is": "^18.3.1",
"react-leaflet": "^5.0.0",
"react-resizable-panels": "^3.0.6",
"react-webcam": "^7.2.0",
"react-window": "^2.1.0",
"react-zoom-pan-pinch": "^3.7.0",
"reactflow": "^11.11.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",

View File

@ -95,7 +95,6 @@ export interface RepeaterFieldGroupConfig {
layout?: "grid" | "card"; // 레이아웃 타입: grid(테이블 행) 또는 card(카드 형식)
showDivider?: boolean; // 항목 사이 구분선 표시 (카드 모드일 때만)
emptyMessage?: string; // 항목이 없을 때 메시지
subDataLookup?: SubDataLookupConfig; // 하위 데이터 조회 설정 (재고, 단가 등)
}
/**
@ -107,71 +106,3 @@ export type RepeaterItemData = Record<string, any>;
* ()
*/
export type RepeaterData = RepeaterItemData[];
// ============================================================
// 하위 데이터 조회 설정 (Sub Data Lookup)
// 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능
// ============================================================
/**
*
*/
export interface SubDataLookupSettings {
tableName: string; // 조회할 테이블 (예: inventory, price_list)
linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code)
displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"])
columnLabels?: Record<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" })
additionalFilters?: Record<string, any>; // 추가 필터 조건
}
/**
*
*/
export interface SubDataSelectionSettings {
mode: "single" | "multiple"; // 단일/다중 선택
requiredFields: string[]; // 필수 선택 필드 (예: ["warehouse_code"])
requiredMode?: "any" | "all"; // 필수 조건: "any" = 하나만, "all" = 모두 (기본: "all")
}
/**
*
*/
export interface ConditionalInputSettings {
targetField: string; // 활성화할 입력 필드 (예: "outbound_qty")
maxValueField?: string; // 최대값 참조 필드 (예: "quantity" - 재고 수량)
warningThreshold?: number; // 경고 임계값 (퍼센트, 예: 90)
errorMessage?: string; // 에러 메시지
}
/**
* UI
*/
export interface SubDataUISettings {
expandMode: "inline" | "modal"; // 확장 방식 (인라인 또는 모달)
maxHeight?: string; // 최대 높이 (예: "150px")
showSummary?: boolean; // 요약 정보 표시
emptyMessage?: string; // 데이터 없을 때 메시지
}
/**
*
*/
export interface SubDataLookupConfig {
enabled: boolean; // 기능 활성화 여부
lookup: SubDataLookupSettings; // 조회 설정
selection: SubDataSelectionSettings; // 선택 설정
conditionalInput: ConditionalInputSettings; // 조건부 입력 설정
ui?: SubDataUISettings; // UI 설정
}
/**
* ()
*/
export interface SubDataState {
itemIndex: number; // 상위 항목 인덱스
data: any[]; // 조회된 하위 데이터
selectedItem: any | null; // 선택된 하위 항목
isLoading: boolean; // 로딩 상태
error: string | null; // 에러 메시지
isExpanded: boolean; // 확장 상태
}

View File

@ -1690,4 +1690,3 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -537,4 +537,3 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -524,4 +524,3 @@ function ScreenViewPage() {