Compare commits
No commits in common. "main" and "fix/split-panel-edit-group-records" have entirely different histories.
main
...
fix/split-
|
|
@ -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
70
PLAN.MD
|
|
@ -1,72 +1,4 @@
|
||||||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||||
|
|
||||||
## 개요
|
|
||||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
|
||||||
|
|
||||||
## 핵심 기능
|
|
||||||
|
|
||||||
### 1. 단일 화면 복제
|
|
||||||
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
|
||||||
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
|
||||||
- [x] 연결된 모달 화면 함께 복제
|
|
||||||
- [x] 대상 그룹 선택 가능
|
|
||||||
- [x] 복제 후 목록 자동 새로고침
|
|
||||||
|
|
||||||
### 2. 그룹(폴더) 전체 복제
|
|
||||||
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
|
||||||
- [x] 정렬 순서(display_order) 유지
|
|
||||||
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
|
||||||
- [x] 정렬 순서 입력 필드 추가
|
|
||||||
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
|
|
||||||
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
|
|
||||||
|
|
||||||
### 3. 고급 옵션: 이름 일괄 변경
|
|
||||||
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
|
|
||||||
- [x] 미리보기 기능
|
|
||||||
|
|
||||||
### 4. 삭제 기능
|
|
||||||
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
|
||||||
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
|
|
||||||
- [x] 삭제 시 로딩 프로그레스 바 표시
|
|
||||||
|
|
||||||
### 5. 화면 수정 기능
|
|
||||||
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
|
|
||||||
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
|
|
||||||
|
|
||||||
### 6. 테이블 설정 기능 (TableSettingModal)
|
|
||||||
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
|
|
||||||
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
|
|
||||||
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
|
|
||||||
- 코드→다른 타입: codeCategory, codeValue 초기화
|
|
||||||
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
|
|
||||||
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
|
|
||||||
|
|
||||||
### 7. 회사 코드 지원 (최고 관리자)
|
|
||||||
- [x] 대상 회사 선택 가능
|
|
||||||
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
|
|
||||||
|
|
||||||
## 관련 파일
|
|
||||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
|
||||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
|
||||||
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
|
|
||||||
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
|
|
||||||
- `frontend/lib/api/screen.ts` - 화면 API
|
|
||||||
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
|
||||||
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
|
|
||||||
|
|
||||||
## 진행 상태
|
|
||||||
- [완료] 단일 화면 복제 + 새로고침
|
|
||||||
- [완료] 그룹 전체 복제 (재귀적)
|
|
||||||
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
|
|
||||||
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
|
|
||||||
- [완료] 화면 수정 (이름/그룹/역할/순서)
|
|
||||||
- [완료] 테이블 설정 탭 추가
|
|
||||||
- [완료] 입력 타입 변경 시 관련 필드 초기화
|
|
||||||
- [완료] 그룹 복제 모달 스크롤 문제 수정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
|
|
|
||||||
|
|
@ -1044,7 +1044,6 @@
|
||||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.3",
|
"@babel/generator": "^7.28.3",
|
||||||
|
|
@ -2372,7 +2371,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -3476,7 +3474,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3713,7 +3710,6 @@
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
|
|
@ -3931,7 +3927,6 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -4458,7 +4453,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.8.3",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
|
|
@ -5669,7 +5663,6 @@
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
|
|
@ -7432,7 +7425,6 @@
|
||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
|
|
@ -8402,6 +8394,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -9290,7 +9283,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
@ -10141,6 +10133,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -10949,7 +10942,6 @@
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
|
@ -11055,7 +11047,6 @@
|
||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
|
|
@ -198,7 +197,6 @@ app.use("/api/multilang", multilangRoutes);
|
||||||
app.use("/api/table-management", tableManagementRoutes);
|
app.use("/api/table-management", tableManagementRoutes);
|
||||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||||
app.use("/api/screen-management", screenManagementRoutes);
|
app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
|
|
||||||
|
|
@ -553,24 +553,10 @@ export const setUserLocale = async (
|
||||||
|
|
||||||
const { locale } = req.body;
|
const { locale } = req.body;
|
||||||
|
|
||||||
if (!locale) {
|
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "로케일이 필요합니다.",
|
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
|
||||||
const validLang = await queryOne<{ lang_code: string }>(
|
|
||||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
|
||||||
[locale]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validLang) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: `유효하지 않은 로케일입니다: ${locale}`,
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1179,33 +1165,6 @@ export async function saveMenu(
|
||||||
|
|
||||||
logger.info("메뉴 저장 성공", { savedMenu });
|
logger.info("메뉴 저장 성공", { savedMenu });
|
||||||
|
|
||||||
// 다국어 메뉴 카테고리 자동 생성
|
|
||||||
try {
|
|
||||||
const { MultiLangService } = await import("../services/multilangService");
|
|
||||||
const multilangService = new MultiLangService();
|
|
||||||
|
|
||||||
// 회사명 조회
|
|
||||||
const companyInfo = await queryOne<{ company_name: string }>(
|
|
||||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
|
||||||
[companyCode]
|
|
||||||
);
|
|
||||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
|
||||||
|
|
||||||
// 메뉴 경로 조회 및 카테고리 생성
|
|
||||||
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
|
||||||
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
|
||||||
|
|
||||||
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
|
||||||
menuObjId: savedMenu.objid.toString(),
|
|
||||||
menuPath,
|
|
||||||
});
|
|
||||||
} catch (categoryError) {
|
|
||||||
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
|
||||||
menuObjId: savedMenu.objid.toString(),
|
|
||||||
error: categoryError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||||
|
|
@ -1417,75 +1376,6 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수
|
|
||||||
*/
|
|
||||||
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
|
||||||
const allIds: number[] = [];
|
|
||||||
|
|
||||||
// 직접 자식 메뉴들 조회
|
|
||||||
const children = await query<any>(
|
|
||||||
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
|
|
||||||
[parentObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
allIds.push(child.objid);
|
|
||||||
// 자식의 자식들도 재귀적으로 수집
|
|
||||||
const grandChildren = await collectAllChildMenuIds(child.objid);
|
|
||||||
allIds.push(...grandChildren);
|
|
||||||
}
|
|
||||||
|
|
||||||
return allIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
|
||||||
*/
|
|
||||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
|
||||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
|
||||||
await query(
|
|
||||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 7. screen_groups에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
|
||||||
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 삭제
|
* 메뉴 삭제
|
||||||
*/
|
*/
|
||||||
|
|
@ -1512,7 +1402,7 @@ export async function deleteMenu(
|
||||||
|
|
||||||
// 삭제하려는 메뉴 조회
|
// 삭제하려는 메뉴 조회
|
||||||
const currentMenu = await queryOne<any>(
|
const currentMenu = await queryOne<any>(
|
||||||
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
|
||||||
[Number(menuId)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1547,50 +1437,67 @@ export async function deleteMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||||
const menuObjid = Number(menuId);
|
const menuObjid = Number(menuId);
|
||||||
|
|
||||||
// 하위 메뉴들 재귀적으로 수집
|
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||||
const childMenuIds = await collectAllChildMenuIds(menuObjid);
|
await query(
|
||||||
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
|
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}개`, {
|
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||||
menuName: currentMenu.menu_name_kor,
|
await query(
|
||||||
totalCount: allMenuIdsToDelete.length,
|
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
childMenuIds,
|
[menuObjid]
|
||||||
});
|
);
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
|
||||||
for (const objid of allMenuIdsToDelete) {
|
|
||||||
await cleanupMenuRelatedData(objid);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
|
||||||
menuObjid,
|
|
||||||
totalCleaned: allMenuIdsToDelete.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
|
|
||||||
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
|
|
||||||
const reversedIds = [...allMenuIdsToDelete].reverse();
|
|
||||||
|
|
||||||
for (const objid of reversedIds) {
|
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||||
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
|
await query(
|
||||||
}
|
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||||
|
await query(
|
||||||
|
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info("메뉴 삭제 성공", {
|
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||||
deletedMenuObjid: menuObjid,
|
|
||||||
deletedMenuName: currentMenu.menu_name_kor,
|
// Raw Query를 사용한 메뉴 삭제
|
||||||
totalDeleted: allMenuIdsToDelete.length,
|
const [deletedMenu] = await query<any>(
|
||||||
});
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
|
[menuObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
|
message: "메뉴가 성공적으로 삭제되었습니다.",
|
||||||
data: {
|
data: {
|
||||||
objid: menuObjid.toString(),
|
objid: deletedMenu.objid.toString(),
|
||||||
menuNameKor: currentMenu.menu_name_kor,
|
menuNameKor: deletedMenu.menu_name_kor,
|
||||||
deletedCount: allMenuIdsToDelete.length,
|
menuNameEng: deletedMenu.menu_name_eng,
|
||||||
deletedChildCount: childMenuIds.length,
|
menuUrl: deletedMenu.menu_url,
|
||||||
|
menuDesc: deletedMenu.menu_desc,
|
||||||
|
status: deletedMenu.status,
|
||||||
|
writer: deletedMenu.writer,
|
||||||
|
regdate: new Date(deletedMenu.regdate).toISOString(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1675,49 +1582,18 @@ export async function deleteMenusBatch(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
|
|
||||||
const allMenuIdsToDelete = new Set<number>();
|
|
||||||
|
|
||||||
for (const menuId of menuIds) {
|
|
||||||
const objid = Number(menuId);
|
|
||||||
allMenuIdsToDelete.add(objid);
|
|
||||||
|
|
||||||
// 하위 메뉴들 재귀적으로 수집
|
|
||||||
const childMenuIds = await collectAllChildMenuIds(objid);
|
|
||||||
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const allIdsArray = Array.from(allMenuIdsToDelete);
|
|
||||||
|
|
||||||
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, {
|
|
||||||
selectedMenuIds: menuIds,
|
|
||||||
totalWithChildren: allIdsArray.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
|
||||||
for (const objid of allIdsArray) {
|
|
||||||
await cleanupMenuRelatedData(objid);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
|
||||||
totalCleaned: allIdsArray.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 일괄 삭제
|
// Raw Query를 사용한 메뉴 일괄 삭제
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
const deletedMenus: any[] = [];
|
const deletedMenus: any[] = [];
|
||||||
const failedMenuIds: string[] = [];
|
const failedMenuIds: string[] = [];
|
||||||
|
|
||||||
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
|
|
||||||
const reversedIds = [...allIdsArray].reverse();
|
|
||||||
|
|
||||||
// 각 메뉴 ID에 대해 삭제 시도
|
// 각 메뉴 ID에 대해 삭제 시도
|
||||||
for (const menuObjid of reversedIds) {
|
for (const menuId of menuIds) {
|
||||||
try {
|
try {
|
||||||
const result = await query<any>(
|
const result = await query<any>(
|
||||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||||
[menuObjid]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|
@ -1728,20 +1604,20 @@ export async function deleteMenusBatch(
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(String(menuObjid));
|
failedMenuIds.push(menuId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
|
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
|
||||||
failedCount++;
|
failedCount++;
|
||||||
failedMenuIds.push(String(menuObjid));
|
failedMenuIds.push(menuId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("메뉴 일괄 삭제 완료", {
|
logger.info("메뉴 일괄 삭제 완료", {
|
||||||
requested: menuIds.length,
|
total: menuIds.length,
|
||||||
totalWithChildren: allIdsArray.length,
|
|
||||||
deletedCount,
|
deletedCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
|
deletedMenus,
|
||||||
failedMenuIds,
|
failedMenuIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2773,24 +2649,6 @@ export const createCompany = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다국어 카테고리 자동 생성
|
|
||||||
try {
|
|
||||||
const { MultiLangService } = await import("../services/multilangService");
|
|
||||||
const multilangService = new MultiLangService();
|
|
||||||
await multilangService.ensureCompanyCategory(
|
|
||||||
createdCompany.company_code,
|
|
||||||
createdCompany.company_name
|
|
||||||
);
|
|
||||||
logger.info("회사 다국어 카테고리 생성 완료", {
|
|
||||||
companyCode: createdCompany.company_code,
|
|
||||||
});
|
|
||||||
} catch (categoryError) {
|
|
||||||
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
|
|
||||||
companyCode: createdCompany.company_code,
|
|
||||||
error: categoryError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("회사 등록 성공", {
|
logger.info("회사 등록 성공", {
|
||||||
companyCode: createdCompany.company_code,
|
companyCode: createdCompany.company_code,
|
||||||
companyName: createdCompany.company_name,
|
companyName: createdCompany.company_name,
|
||||||
|
|
@ -3200,23 +3058,6 @@ export const updateProfile = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locale !== undefined) {
|
if (locale !== undefined) {
|
||||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
|
||||||
const validLang = await queryOne<{ lang_code: string }>(
|
|
||||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
|
||||||
[locale]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!validLang) {
|
|
||||||
res.status(400).json({
|
|
||||||
result: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_LOCALE",
|
|
||||||
details: `유효하지 않은 로케일입니다: ${locale}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFields.push(`locale = $${paramIndex}`);
|
updateFields.push(`locale = $${paramIndex}`);
|
||||||
updateValues.push(locale);
|
updateValues.push(locale);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName, screenId } = req.body;
|
const { tableName } = req.body;
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -240,16 +240,7 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(
|
|
||||||
id,
|
|
||||||
tableName,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export class EntityJoinController {
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
|
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -66,23 +67,11 @@ export class EntityJoinController {
|
||||||
const userField = parsedAutoFilter.userField || "companyCode";
|
const userField = parsedAutoFilter.userField || "companyCode";
|
||||||
const userValue = ((req as any).user as any)[userField];
|
const userValue = ((req as any).user as any)[userField];
|
||||||
|
|
||||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
if (userValue) {
|
||||||
let finalCompanyCode = userValue;
|
searchConditions[filterColumn] = userValue;
|
||||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
|
||||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
|
||||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
|
||||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
|
||||||
originalCompanyCode: userValue,
|
|
||||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
|
||||||
tableName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalCompanyCode) {
|
|
||||||
searchConditions[filterColumn] = finalCompanyCode;
|
|
||||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
finalCompanyCode,
|
userValue,
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +140,24 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정 처리
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined = undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication =
|
||||||
|
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
|
||||||
|
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("중복 제거 설정 파싱 오류:", error);
|
||||||
|
parsedDeduplication = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -168,13 +175,26 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리 (결과 데이터에 적용)
|
||||||
|
let finalData = result;
|
||||||
|
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
|
||||||
|
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
|
||||||
|
const originalCount = result.data.length;
|
||||||
|
finalData = {
|
||||||
|
...result,
|
||||||
|
data: this.deduplicateData(result.data, parsedDeduplication),
|
||||||
|
};
|
||||||
|
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Entity 조인 데이터 조회 성공",
|
message: "Entity 조인 데이터 조회 성공",
|
||||||
data: result,
|
data: finalData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||||
|
|
@ -549,6 +569,98 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price가 true인 행 선택
|
||||||
|
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// 오늘 날짜 기준 유효 기간 내 행 선택
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
selectedRow = rows.find((r) => {
|
||||||
|
const startDate = r.start_date;
|
||||||
|
const endDate = r.end_date;
|
||||||
|
if (!startDate) return true;
|
||||||
|
if (startDate <= today && (!endDate || endDate >= today)) return true;
|
||||||
|
return false;
|
||||||
|
}) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRow) {
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entityJoinController = new EntityJoinController();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ import {
|
||||||
SaveLangTextsRequest,
|
SaveLangTextsRequest,
|
||||||
GetUserTextParams,
|
GetUserTextParams,
|
||||||
BatchTranslationRequest,
|
BatchTranslationRequest,
|
||||||
GenerateKeyRequest,
|
|
||||||
CreateOverrideKeyRequest,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
LangCategory,
|
|
||||||
} from "../types/multilang";
|
} from "../types/multilang";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -190,7 +187,7 @@ export const getLangKeys = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
const { companyCode, menuCode, keyType, searchText } = req.query;
|
||||||
logger.info("다국어 키 목록 조회 요청", {
|
logger.info("다국어 키 목록 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
|
|
@ -202,7 +199,6 @@ export const getLangKeys = async (
|
||||||
menuCode: menuCode as string,
|
menuCode: menuCode as string,
|
||||||
keyType: keyType as string,
|
keyType: keyType as string,
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
|
|
@ -634,391 +630,6 @@ export const deleteLanguage = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 카테고리 관련 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multilang/categories
|
|
||||||
* 카테고리 목록 조회 API (트리 구조)
|
|
||||||
*/
|
|
||||||
export const getCategories = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
logger.info("카테고리 목록 조회 요청", { user: req.user });
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const categories = await multiLangService.getCategories();
|
|
||||||
|
|
||||||
const response: ApiResponse<LangCategory[]> = {
|
|
||||||
success: true,
|
|
||||||
message: "카테고리 목록 조회 성공",
|
|
||||||
data: categories,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("카테고리 목록 조회 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "CATEGORY_LIST_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multilang/categories/:categoryId
|
|
||||||
* 카테고리 상세 조회 API
|
|
||||||
*/
|
|
||||||
export const getCategoryById = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { categoryId } = req.params;
|
|
||||||
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const category = await multiLangService.getCategoryById(parseInt(categoryId));
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: "카테고리를 찾을 수 없습니다.",
|
|
||||||
error: {
|
|
||||||
code: "CATEGORY_NOT_FOUND",
|
|
||||||
details: `Category ID ${categoryId} not found`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ApiResponse<LangCategory> = {
|
|
||||||
success: true,
|
|
||||||
message: "카테고리 상세 조회 성공",
|
|
||||||
data: category,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("카테고리 상세 조회 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "CATEGORY_DETAIL_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multilang/categories/:categoryId/path
|
|
||||||
* 카테고리 경로 조회 API (부모 포함)
|
|
||||||
*/
|
|
||||||
export const getCategoryPath = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { categoryId } = req.params;
|
|
||||||
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
|
|
||||||
|
|
||||||
const response: ApiResponse<LangCategory[]> = {
|
|
||||||
success: true,
|
|
||||||
message: "카테고리 경로 조회 성공",
|
|
||||||
data: path,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("카테고리 경로 조회 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "CATEGORY_PATH_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// =====================================================
|
|
||||||
// 자동 생성 및 오버라이드 관련 API
|
|
||||||
// =====================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multilang/keys/generate
|
|
||||||
* 키 자동 생성 API
|
|
||||||
*/
|
|
||||||
export const generateKey = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const generateData: GenerateKeyRequest = req.body;
|
|
||||||
logger.info("키 자동 생성 요청", { generateData, user: req.user });
|
|
||||||
|
|
||||||
// 필수 입력값 검증
|
|
||||||
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
|
|
||||||
error: {
|
|
||||||
code: "MISSING_REQUIRED_FIELDS",
|
|
||||||
details: "companyCode, categoryId, and keyMeaning are required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
|
||||||
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
|
||||||
error: {
|
|
||||||
code: "PERMISSION_DENIED",
|
|
||||||
details: "Only super admin can create common keys",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사 관리자는 자기 회사 키만 생성 가능
|
|
||||||
if (generateData.companyCode !== "*" &&
|
|
||||||
req.user?.companyCode !== "*" &&
|
|
||||||
generateData.companyCode !== req.user?.companyCode) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
|
||||||
error: {
|
|
||||||
code: "PERMISSION_DENIED",
|
|
||||||
details: "Cannot create keys for other companies",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const keyId = await multiLangService.generateKey({
|
|
||||||
...generateData,
|
|
||||||
createdBy: req.user?.userId || "system",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<number> = {
|
|
||||||
success: true,
|
|
||||||
message: "키가 성공적으로 생성되었습니다.",
|
|
||||||
data: keyId,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(201).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("키 자동 생성 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "키 자동 생성 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "KEY_GENERATE_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multilang/keys/preview
|
|
||||||
* 키 미리보기 API
|
|
||||||
*/
|
|
||||||
export const previewKey = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { categoryId, keyMeaning, companyCode } = req.body;
|
|
||||||
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
|
|
||||||
|
|
||||||
if (!categoryId || !keyMeaning || !companyCode) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
|
|
||||||
error: {
|
|
||||||
code: "MISSING_REQUIRED_FIELDS",
|
|
||||||
details: "categoryId, keyMeaning, and companyCode are required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const preview = await multiLangService.previewGeneratedKey(
|
|
||||||
parseInt(categoryId),
|
|
||||||
keyMeaning,
|
|
||||||
companyCode
|
|
||||||
);
|
|
||||||
|
|
||||||
const response: ApiResponse<{
|
|
||||||
langKey: string;
|
|
||||||
exists: boolean;
|
|
||||||
isOverride: boolean;
|
|
||||||
baseKeyId?: number;
|
|
||||||
}> = {
|
|
||||||
success: true,
|
|
||||||
message: "키 미리보기 성공",
|
|
||||||
data: preview,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("키 미리보기 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "키 미리보기 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "KEY_PREVIEW_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multilang/keys/override
|
|
||||||
* 오버라이드 키 생성 API
|
|
||||||
*/
|
|
||||||
export const createOverrideKey = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const overrideData: CreateOverrideKeyRequest = req.body;
|
|
||||||
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
|
|
||||||
|
|
||||||
// 필수 입력값 검증
|
|
||||||
if (!overrideData.companyCode || !overrideData.baseKeyId) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "회사 코드와 원본 키 ID는 필수입니다.",
|
|
||||||
error: {
|
|
||||||
code: "MISSING_REQUIRED_FIELDS",
|
|
||||||
details: "companyCode and baseKeyId are required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
|
|
||||||
if (overrideData.companyCode === "*") {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
|
|
||||||
error: {
|
|
||||||
code: "INVALID_OVERRIDE",
|
|
||||||
details: "Cannot create override for common keys",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
|
|
||||||
if (req.user?.companyCode !== "*" &&
|
|
||||||
overrideData.companyCode !== req.user?.companyCode) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
|
|
||||||
error: {
|
|
||||||
code: "PERMISSION_DENIED",
|
|
||||||
details: "Cannot create override keys for other companies",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const keyId = await multiLangService.createOverrideKey({
|
|
||||||
...overrideData,
|
|
||||||
createdBy: req.user?.userId || "system",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<number> = {
|
|
||||||
success: true,
|
|
||||||
message: "오버라이드 키가 성공적으로 생성되었습니다.",
|
|
||||||
data: keyId,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(201).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("오버라이드 키 생성 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "OVERRIDE_KEY_CREATE_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/multilang/keys/overrides/:companyCode
|
|
||||||
* 회사별 오버라이드 키 목록 조회 API
|
|
||||||
*/
|
|
||||||
export const getOverrideKeys = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { companyCode } = req.params;
|
|
||||||
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
|
|
||||||
|
|
||||||
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
|
|
||||||
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
|
|
||||||
res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
|
|
||||||
error: {
|
|
||||||
code: "PERMISSION_DENIED",
|
|
||||||
details: "Cannot view override keys for other companies",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const keys = await multiLangService.getOverrideKeys(companyCode);
|
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
|
||||||
success: true,
|
|
||||||
message: "오버라이드 키 목록 조회 성공",
|
|
||||||
data: keys,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("오버라이드 키 목록 조회 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "OVERRIDE_KEYS_LIST_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/multilang/batch
|
* POST /api/multilang/batch
|
||||||
* 다국어 텍스트 배치 조회 API
|
* 다국어 텍스트 배치 조회 API
|
||||||
|
|
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/multilang/screen-labels
|
|
||||||
* 화면 라벨 다국어 키 자동 생성 API
|
|
||||||
*/
|
|
||||||
export const generateScreenLabelKeys = async (
|
|
||||||
req: AuthenticatedRequest,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const { screenId, menuObjId, labels } = req.body;
|
|
||||||
|
|
||||||
logger.info("화면 라벨 다국어 키 생성 요청", {
|
|
||||||
screenId,
|
|
||||||
menuObjId,
|
|
||||||
labelCount: labels?.length,
|
|
||||||
user: req.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 필수 파라미터 검증
|
|
||||||
if (!screenId) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId는 필수입니다.",
|
|
||||||
error: { code: "MISSING_SCREEN_ID" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
|
||||||
res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "labels 배열이 필요합니다.",
|
|
||||||
error: { code: "MISSING_LABELS" },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
|
|
||||||
const { queryOne } = await import("../database/db");
|
|
||||||
const screenInfo = await queryOne<{ company_code: string }>(
|
|
||||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
|
|
||||||
[screenId]
|
|
||||||
);
|
|
||||||
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
|
|
||||||
|
|
||||||
// 회사명 조회
|
|
||||||
const companyInfo = await queryOne<{ company_name: string }>(
|
|
||||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
|
||||||
[companyCode]
|
|
||||||
);
|
|
||||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
|
||||||
|
|
||||||
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
|
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
|
||||||
const results = await multiLangService.generateScreenLabelKeys({
|
|
||||||
screenId: Number(screenId),
|
|
||||||
companyCode,
|
|
||||||
companyName,
|
|
||||||
menuObjId,
|
|
||||||
labels,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof results> = {
|
|
||||||
success: true,
|
|
||||||
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
|
||||||
data: results,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(200).json(response);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
|
||||||
error: {
|
|
||||||
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
|
||||||
details: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -775,25 +775,18 @@ export async function getTableData(
|
||||||
const userField = autoFilter?.userField || "companyCode";
|
const userField = autoFilter?.userField || "companyCode";
|
||||||
const userValue = (req.user as any)[userField];
|
const userValue = (req.user as any)[userField];
|
||||||
|
|
||||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
|
||||||
let finalCompanyCode = userValue;
|
if (userValue && userValue !== "*") {
|
||||||
if (autoFilter?.companyCodeOverride && userValue === "*") {
|
enhancedSearch[filterColumn] = userValue;
|
||||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
|
||||||
finalCompanyCode = autoFilter.companyCodeOverride;
|
|
||||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
|
||||||
originalCompanyCode: userValue,
|
|
||||||
overrideCompanyCode: autoFilter.companyCodeOverride,
|
|
||||||
tableName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (finalCompanyCode) {
|
|
||||||
enhancedSearch[filterColumn] = finalCompanyCode;
|
|
||||||
|
|
||||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||||
filterColumn,
|
filterColumn,
|
||||||
userField,
|
userField,
|
||||||
userValue: finalCompanyCode,
|
userValue,
|
||||||
|
tableName,
|
||||||
|
});
|
||||||
|
} else if (userValue === "*") {
|
||||||
|
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -804,6 +797,9 @@ export async function getTableData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 최종 검색 조건 로그
|
||||||
|
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회
|
||||||
const result = await tableManagementService.getTableData(tableName, {
|
const result = await tableManagementService.getTableData(tableName, {
|
||||||
page: parseInt(page),
|
page: parseInt(page),
|
||||||
|
|
@ -905,13 +901,23 @@ export async function addTableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
await tableManagementService.addTableData(tableName, data);
|
const result = await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
// 무시된 컬럼이 있으면 경고 정보 포함
|
||||||
|
const response: ApiResponse<{
|
||||||
|
skippedColumns?: string[];
|
||||||
|
savedColumns?: string[];
|
||||||
|
}> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
message: result.skippedColumns.length > 0
|
||||||
|
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
|
||||||
|
: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||||
|
data: {
|
||||||
|
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
|
||||||
|
savedColumns: result.savedColumns,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
|
|
@ -2180,8 +2186,11 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 두 테이블 간 엔티티 관계 조회
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
* column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회
|
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||||
|
*
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
*/
|
*/
|
||||||
export async function getTableEntityRelations(
|
export async function getTableEntityRelations(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -2190,93 +2199,53 @@ export async function getTableEntityRelations(
|
||||||
try {
|
try {
|
||||||
const { leftTable, rightTable } = req.query;
|
const { leftTable, rightTable } = req.query;
|
||||||
|
|
||||||
|
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
|
||||||
|
|
||||||
if (!leftTable || !rightTable) {
|
if (!leftTable || !rightTable) {
|
||||||
res.status(400).json({
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
success: false,
|
||||||
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
||||||
});
|
error: {
|
||||||
|
code: "MISSING_PARAMETERS",
|
||||||
|
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
|
const tableManagementService = new TableManagementService();
|
||||||
|
const relations = await tableManagementService.detectTableEntityRelations(
|
||||||
|
String(leftTable),
|
||||||
|
String(rightTable)
|
||||||
|
);
|
||||||
|
|
||||||
// 두 테이블의 컬럼 라벨 정보 조회
|
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
|
||||||
const columnLabelsQuery = `
|
|
||||||
SELECT
|
|
||||||
table_name,
|
|
||||||
column_name,
|
|
||||||
column_label,
|
|
||||||
web_type,
|
|
||||||
detail_settings
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name IN ($1, $2)
|
|
||||||
AND web_type IN ('entity', 'category')
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
|
const response: ApiResponse<any> = {
|
||||||
|
|
||||||
// 관계 분석
|
|
||||||
const relations: Array<{
|
|
||||||
fromTable: string;
|
|
||||||
fromColumn: string;
|
|
||||||
toTable: string;
|
|
||||||
toColumn: string;
|
|
||||||
relationType: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const row of result) {
|
|
||||||
try {
|
|
||||||
const detailSettings = typeof row.detail_settings === "string"
|
|
||||||
? JSON.parse(row.detail_settings)
|
|
||||||
: row.detail_settings;
|
|
||||||
|
|
||||||
if (detailSettings && detailSettings.referenceTable) {
|
|
||||||
const refTable = detailSettings.referenceTable;
|
|
||||||
const refColumn = detailSettings.referenceColumn || "id";
|
|
||||||
|
|
||||||
// leftTable과 rightTable 간의 관계인지 확인
|
|
||||||
if (
|
|
||||||
(row.table_name === leftTable && refTable === rightTable) ||
|
|
||||||
(row.table_name === rightTable && refTable === leftTable)
|
|
||||||
) {
|
|
||||||
relations.push({
|
|
||||||
fromTable: row.table_name,
|
|
||||||
fromColumn: row.column_name,
|
|
||||||
toTable: refTable,
|
|
||||||
toColumn: refColumn,
|
|
||||||
relationType: row.web_type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
logger.warn("detail_settings 파싱 오류:", {
|
|
||||||
table: row.table_name,
|
|
||||||
column: row.column_name,
|
|
||||||
error: parseError
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("테이블 엔티티 관계 조회 완료", {
|
|
||||||
leftTable,
|
|
||||||
rightTable,
|
|
||||||
relationsCount: relations.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
success: true,
|
||||||
|
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
|
||||||
data: {
|
data: {
|
||||||
leftTable,
|
leftTable: String(leftTable),
|
||||||
rightTable,
|
rightTable: String(rightTable),
|
||||||
relations,
|
relations,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("테이블 엔티티 관계 조회 실패:", error);
|
res.status(200).json(response);
|
||||||
res.status(500).json({
|
} catch (error) {
|
||||||
|
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 엔티티 관계 조회에 실패했습니다.",
|
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
|
||||||
error: error.message,
|
error: {
|
||||||
});
|
code: "ENTITY_RELATIONS_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,3 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,3 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,3 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,4 +56,3 @@ export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,262 +1,10 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 마스터-디테일 엑셀 API
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보 조회
|
|
||||||
* GET /api/data/master-detail/relation/:screenId
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/master-detail/relation/:screenId",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId } = req.params;
|
|
||||||
|
|
||||||
if (!screenId || isNaN(parseInt(screenId))) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "유효한 screenId가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
|
||||||
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: null,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
|
||||||
masterTable: relation.masterTable,
|
|
||||||
detailTable: relation.detailTable,
|
|
||||||
joinKey: relation.masterKeyColumn,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: relation,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 관계 조회 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
|
||||||
* POST /api/data/master-detail/download
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/download",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, filters } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
|
|
||||||
if (!screenId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId가 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 조회
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. JOIN 데이터 조회
|
|
||||||
const data = await masterDetailExcelService.getJoinedData(
|
|
||||||
relation,
|
|
||||||
companyCode,
|
|
||||||
filters
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 다운로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 업로드
|
|
||||||
* POST /api/data/master-detail/upload
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/upload",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, data } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
const userId = req.user?.userId;
|
|
||||||
|
|
||||||
if (!screenId || !data || !Array.isArray(data)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId와 data 배열이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 조회
|
|
||||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
|
||||||
parseInt(screenId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relation) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 구조가 아닙니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 데이터 업로드
|
|
||||||
const result = await masterDetailExcelService.uploadJoinedData(
|
|
||||||
relation,
|
|
||||||
data,
|
|
||||||
companyCode,
|
|
||||||
userId
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
masterUpdated: result.masterUpdated,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: result.success,
|
|
||||||
data: result,
|
|
||||||
message: result.success
|
|
||||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
|
||||||
: "업로드 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 업로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 간단 모드 엑셀 업로드
|
|
||||||
* - 마스터 정보는 UI에서 선택
|
|
||||||
* - 디테일 정보만 엑셀에서 업로드
|
|
||||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
|
||||||
*
|
|
||||||
* POST /api/data/master-detail/upload-simple
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/master-detail/upload-simple",
|
|
||||||
authenticateToken,
|
|
||||||
async (req: AuthenticatedRequest, res) => {
|
|
||||||
try {
|
|
||||||
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
|
||||||
const companyCode = req.user?.companyCode || "*";
|
|
||||||
const userId = req.user?.userId || "system";
|
|
||||||
|
|
||||||
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "screenId와 detailData 배열이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
|
||||||
console.log(` 마스터 필드 값:`, masterFieldValues);
|
|
||||||
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
|
||||||
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
|
||||||
|
|
||||||
// 업로드 실행
|
|
||||||
const result = await masterDetailExcelService.uploadSimple(
|
|
||||||
parseInt(screenId),
|
|
||||||
detailData,
|
|
||||||
masterFieldValues || {},
|
|
||||||
numberingRuleId,
|
|
||||||
companyCode,
|
|
||||||
userId,
|
|
||||||
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
|
||||||
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
generatedKey: result.generatedKey,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: result.success,
|
|
||||||
data: result,
|
|
||||||
message: result.success
|
|
||||||
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
|
||||||
: "업로드 중 오류가 발생했습니다.",
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 기존 데이터 API
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,6 @@ import {
|
||||||
getUserText,
|
getUserText,
|
||||||
getLangText,
|
getLangText,
|
||||||
getBatchTranslations,
|
getBatchTranslations,
|
||||||
|
|
||||||
// 카테고리 관리 API
|
|
||||||
getCategories,
|
|
||||||
getCategoryById,
|
|
||||||
getCategoryPath,
|
|
||||||
|
|
||||||
// 자동 생성 및 오버라이드 API
|
|
||||||
generateKey,
|
|
||||||
previewKey,
|
|
||||||
createOverrideKey,
|
|
||||||
getOverrideKeys,
|
|
||||||
|
|
||||||
// 화면 라벨 다국어 API
|
|
||||||
generateScreenLabelKeys,
|
|
||||||
} from "../controllers/multilangController";
|
} from "../controllers/multilangController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
|
||||||
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
||||||
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
||||||
|
|
||||||
// 카테고리 관리 API
|
|
||||||
router.get("/categories", getCategories); // 카테고리 트리 조회
|
|
||||||
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
|
|
||||||
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
|
|
||||||
|
|
||||||
// 자동 생성 및 오버라이드 API
|
|
||||||
router.post("/keys/generate", generateKey); // 키 자동 생성
|
|
||||||
router.post("/keys/preview", previewKey); // 키 미리보기
|
|
||||||
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
|
||||||
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
|
||||||
|
|
||||||
// 화면 라벨 다국어 자동 생성 API
|
|
||||||
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -254,10 +254,7 @@ class DataService {
|
||||||
key !== "limit" &&
|
key !== "limit" &&
|
||||||
key !== "offset" &&
|
key !== "offset" &&
|
||||||
key !== "orderBy" &&
|
key !== "orderBy" &&
|
||||||
key !== "userLang" &&
|
key !== "userLang"
|
||||||
key !== "page" &&
|
|
||||||
key !== "pageSize" &&
|
|
||||||
key !== "size"
|
|
||||||
) {
|
) {
|
||||||
// 컬럼명 검증 (SQL 인젝션 방지)
|
// 컬럼명 검증 (SQL 인젝션 방지)
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||||
|
|
|
||||||
|
|
@ -1192,18 +1192,12 @@ export class DynamicFormService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
* @param id 삭제할 레코드 ID
|
|
||||||
* @param tableName 테이블명
|
|
||||||
* @param companyCode 회사 코드
|
|
||||||
* @param userId 사용자 ID
|
|
||||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string,
|
companyCode?: string,
|
||||||
userId?: string,
|
userId?: string
|
||||||
screenId?: number
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||||
|
|
@ -1316,19 +1310,14 @@ export class DynamicFormService {
|
||||||
const recordCompanyCode =
|
const recordCompanyCode =
|
||||||
deletedRecord?.company_code || companyCode || "*";
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
await this.executeDataflowControlIfConfigured(
|
||||||
if (screenId && screenId > 0) {
|
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
await this.executeDataflowControlIfConfigured(
|
tableName,
|
||||||
screenId,
|
deletedRecord,
|
||||||
tableName,
|
"delete",
|
||||||
deletedRecord,
|
userId || "system",
|
||||||
"delete",
|
recordCompanyCode
|
||||||
userId || "system",
|
);
|
||||||
recordCompanyCode
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1673,16 +1662,10 @@ export class DynamicFormService {
|
||||||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||||
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
|
||||||
const buttonActionType = properties?.componentConfig?.action?.type;
|
|
||||||
const isMatchingAction =
|
|
||||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
|
||||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
properties?.componentType === "button-primary" &&
|
properties?.componentType === "button-primary" &&
|
||||||
isMatchingAction &&
|
properties?.componentConfig?.action?.type === "save" &&
|
||||||
properties?.webTypeConfig?.enableDataflowControl === true
|
properties?.webTypeConfig?.enableDataflowControl === true
|
||||||
) {
|
) {
|
||||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,908 +0,0 @@
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 처리 서비스
|
|
||||||
*
|
|
||||||
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
|
||||||
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
|
||||||
import { logger } from "../utils/logger";
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 인터페이스 정의
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보
|
|
||||||
*/
|
|
||||||
export interface MasterDetailRelation {
|
|
||||||
masterTable: string;
|
|
||||||
detailTable: string;
|
|
||||||
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
|
||||||
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
|
||||||
masterColumns: ColumnInfo[];
|
|
||||||
detailColumns: ColumnInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컬럼 정보
|
|
||||||
*/
|
|
||||||
export interface ColumnInfo {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
inputType: string;
|
|
||||||
isFromMaster: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 분할 패널 설정
|
|
||||||
*/
|
|
||||||
export interface SplitPanelConfig {
|
|
||||||
leftPanel: {
|
|
||||||
tableName: string;
|
|
||||||
columns: Array<{ name: string; label: string; width?: number }>;
|
|
||||||
};
|
|
||||||
rightPanel: {
|
|
||||||
tableName: string;
|
|
||||||
columns: Array<{ name: string; label: string; width?: number }>;
|
|
||||||
relation?: {
|
|
||||||
type: string;
|
|
||||||
foreignKey?: string;
|
|
||||||
leftColumn?: string;
|
|
||||||
// 복합키 지원 (새로운 방식)
|
|
||||||
keys?: Array<{
|
|
||||||
leftColumn: string;
|
|
||||||
rightColumn: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 다운로드 결과
|
|
||||||
*/
|
|
||||||
export interface ExcelDownloadData {
|
|
||||||
headers: string[]; // 컬럼 라벨들
|
|
||||||
columns: string[]; // 컬럼명들
|
|
||||||
data: Record<string, any>[];
|
|
||||||
masterColumns: string[]; // 마스터 컬럼 목록
|
|
||||||
detailColumns: string[]; // 디테일 컬럼 목록
|
|
||||||
joinKey: string; // 조인 키
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 업로드 결과
|
|
||||||
*/
|
|
||||||
export interface ExcelUploadResult {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
masterUpdated: number;
|
|
||||||
detailInserted: number;
|
|
||||||
detailDeleted: number;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 서비스 클래스
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
class MasterDetailExcelService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 화면 ID로 분할 패널 설정 조회
|
|
||||||
*/
|
|
||||||
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
|
||||||
try {
|
|
||||||
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
|
||||||
|
|
||||||
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
|
||||||
const result = await queryOne<any>(
|
|
||||||
`SELECT properties->>'componentConfig' as config
|
|
||||||
FROM screen_layouts
|
|
||||||
WHERE screen_id = $1
|
|
||||||
AND component_type = 'component'
|
|
||||||
AND properties->>'componentType' = 'split-panel-layout'
|
|
||||||
LIMIT 1`,
|
|
||||||
[screenId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result || !result.config) {
|
|
||||||
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = typeof result.config === "string"
|
|
||||||
? JSON.parse(result.config)
|
|
||||||
: result.config;
|
|
||||||
|
|
||||||
logger.info(`분할 패널 설정 발견:`, {
|
|
||||||
leftTable: config.leftPanel?.tableName,
|
|
||||||
rightTable: config.rightPanel?.tableName,
|
|
||||||
relation: config.rightPanel?.relation,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
leftPanel: config.leftPanel,
|
|
||||||
rightPanel: config.rightPanel,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* column_labels에서 Entity 관계 정보 조회
|
|
||||||
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
|
||||||
*/
|
|
||||||
async getEntityRelation(
|
|
||||||
detailTable: string,
|
|
||||||
masterTable: string
|
|
||||||
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
|
||||||
try {
|
|
||||||
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
|
||||||
|
|
||||||
const result = await queryOne<any>(
|
|
||||||
`SELECT column_name, reference_column
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND input_type = 'entity'
|
|
||||||
AND reference_table = $2
|
|
||||||
LIMIT 1`,
|
|
||||||
[detailTable, masterTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
detailFkColumn: result.column_name,
|
|
||||||
masterKeyColumn: result.reference_column,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블의 컬럼 라벨 정보 조회
|
|
||||||
*/
|
|
||||||
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
|
||||||
try {
|
|
||||||
const result = await query<any>(
|
|
||||||
`SELECT column_name, column_label
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1`,
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelMap = new Map<string, string>();
|
|
||||||
for (const row of result) {
|
|
||||||
labelMap.set(row.column_name, row.column_label || row.column_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return labelMap;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보 조합
|
|
||||||
*/
|
|
||||||
async getMasterDetailRelation(
|
|
||||||
screenId: number
|
|
||||||
): Promise<MasterDetailRelation | null> {
|
|
||||||
try {
|
|
||||||
// 1. 분할 패널 설정 조회
|
|
||||||
const splitPanel = await this.getSplitPanelConfig(screenId);
|
|
||||||
if (!splitPanel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const masterTable = splitPanel.leftPanel.tableName;
|
|
||||||
const detailTable = splitPanel.rightPanel.tableName;
|
|
||||||
|
|
||||||
if (!masterTable || !detailTable) {
|
|
||||||
logger.warn("마스터 또는 디테일 테이블명 없음");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
|
||||||
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
|
||||||
let masterKeyColumn: string | undefined;
|
|
||||||
let detailFkColumn: string | undefined;
|
|
||||||
|
|
||||||
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
|
||||||
if (relationKeys && relationKeys.length > 0) {
|
|
||||||
// keys 배열에서 첫 번째 키 사용
|
|
||||||
masterKeyColumn = relationKeys[0].leftColumn;
|
|
||||||
detailFkColumn = relationKeys[0].rightColumn;
|
|
||||||
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
|
||||||
} else {
|
|
||||||
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
|
||||||
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
|
||||||
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
|
||||||
if (!masterKeyColumn || !detailFkColumn) {
|
|
||||||
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
|
||||||
if (entityRelation) {
|
|
||||||
masterKeyColumn = entityRelation.masterKeyColumn;
|
|
||||||
detailFkColumn = entityRelation.detailFkColumn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!masterKeyColumn || !detailFkColumn) {
|
|
||||||
logger.warn("조인 키 정보를 찾을 수 없음");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 컬럼 라벨 정보 조회
|
|
||||||
const masterLabels = await this.getColumnLabels(masterTable);
|
|
||||||
const detailLabels = await this.getColumnLabels(detailTable);
|
|
||||||
|
|
||||||
// 5. 마스터 컬럼 정보 구성
|
|
||||||
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
|
||||||
name: col.name,
|
|
||||||
label: masterLabels.get(col.name) || col.label || col.name,
|
|
||||||
inputType: "text",
|
|
||||||
isFromMaster: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
|
||||||
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
|
||||||
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
|
||||||
.map(col => ({
|
|
||||||
name: col.name,
|
|
||||||
label: detailLabels.get(col.name) || col.label || col.name,
|
|
||||||
inputType: "text",
|
|
||||||
isFromMaster: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
|
||||||
masterTable,
|
|
||||||
detailTable,
|
|
||||||
masterKeyColumn,
|
|
||||||
detailFkColumn,
|
|
||||||
masterColumnCount: masterColumns.length,
|
|
||||||
detailColumnCount: detailColumns.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
masterTable,
|
|
||||||
detailTable,
|
|
||||||
masterKeyColumn,
|
|
||||||
detailFkColumn,
|
|
||||||
masterColumns,
|
|
||||||
detailColumns,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
|
||||||
*/
|
|
||||||
async getJoinedData(
|
|
||||||
relation: MasterDetailRelation,
|
|
||||||
companyCode: string,
|
|
||||||
filters?: Record<string, any>
|
|
||||||
): Promise<ExcelDownloadData> {
|
|
||||||
try {
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
|
||||||
|
|
||||||
// 조인 컬럼과 일반 컬럼 분리
|
|
||||||
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
|
||||||
const entityJoins: Array<{
|
|
||||||
refTable: string;
|
|
||||||
refColumn: string;
|
|
||||||
sourceColumn: string;
|
|
||||||
alias: string;
|
|
||||||
displayColumn: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// SELECT 절 구성
|
|
||||||
const selectParts: string[] = [];
|
|
||||||
let aliasIndex = 0;
|
|
||||||
|
|
||||||
// 마스터 컬럼 처리
|
|
||||||
for (const col of masterColumns) {
|
|
||||||
if (col.name.includes(".")) {
|
|
||||||
// 조인 컬럼: 테이블명.컬럼명
|
|
||||||
const [refTable, displayColumn] = col.name.split(".");
|
|
||||||
const alias = `ej${aliasIndex++}`;
|
|
||||||
|
|
||||||
// column_labels에서 FK 컬럼 찾기
|
|
||||||
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
|
||||||
if (fkColumn) {
|
|
||||||
entityJoins.push({
|
|
||||||
refTable,
|
|
||||||
refColumn: fkColumn.referenceColumn,
|
|
||||||
sourceColumn: fkColumn.sourceColumn,
|
|
||||||
alias,
|
|
||||||
displayColumn,
|
|
||||||
});
|
|
||||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
||||||
} else {
|
|
||||||
// FK를 못 찾으면 NULL로 처리
|
|
||||||
selectParts.push(`NULL AS "${col.name}"`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 일반 컬럼
|
|
||||||
selectParts.push(`m."${col.name}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디테일 컬럼 처리
|
|
||||||
for (const col of detailColumns) {
|
|
||||||
if (col.name.includes(".")) {
|
|
||||||
// 조인 컬럼: 테이블명.컬럼명
|
|
||||||
const [refTable, displayColumn] = col.name.split(".");
|
|
||||||
const alias = `ej${aliasIndex++}`;
|
|
||||||
|
|
||||||
// column_labels에서 FK 컬럼 찾기
|
|
||||||
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
|
||||||
if (fkColumn) {
|
|
||||||
entityJoins.push({
|
|
||||||
refTable,
|
|
||||||
refColumn: fkColumn.referenceColumn,
|
|
||||||
sourceColumn: fkColumn.sourceColumn,
|
|
||||||
alias,
|
|
||||||
displayColumn,
|
|
||||||
});
|
|
||||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
|
||||||
} else {
|
|
||||||
selectParts.push(`NULL AS "${col.name}"`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 일반 컬럼
|
|
||||||
selectParts.push(`d."${col.name}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClause = selectParts.join(", ");
|
|
||||||
|
|
||||||
// 엔티티 조인 절 구성
|
|
||||||
const entityJoinClauses = entityJoins.map(ej =>
|
|
||||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
|
||||||
).join("\n ");
|
|
||||||
|
|
||||||
// WHERE 절 구성
|
|
||||||
const whereConditions: string[] = [];
|
|
||||||
const params: any[] = [];
|
|
||||||
let paramIndex = 1;
|
|
||||||
|
|
||||||
// 회사 코드 필터 (최고 관리자 제외)
|
|
||||||
if (companyCode && companyCode !== "*") {
|
|
||||||
whereConditions.push(`m.company_code = $${paramIndex}`);
|
|
||||||
params.push(companyCode);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 필터 적용
|
|
||||||
if (filters) {
|
|
||||||
for (const [key, value] of Object.entries(filters)) {
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
// 조인 컬럼인지 확인
|
|
||||||
if (key.includes(".")) continue;
|
|
||||||
// 마스터 테이블 컬럼인지 확인
|
|
||||||
const isMasterCol = masterColumns.some(c => c.name === key);
|
|
||||||
const tableAlias = isMasterCol ? "m" : "d";
|
|
||||||
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
|
||||||
params.push(value);
|
|
||||||
paramIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// JOIN 쿼리 실행
|
|
||||||
const sql = `
|
|
||||||
SELECT ${selectClause}
|
|
||||||
FROM "${masterTable}" m
|
|
||||||
LEFT JOIN "${detailTable}" d
|
|
||||||
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
|
||||||
AND m.company_code = d.company_code
|
|
||||||
${entityJoinClauses}
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY m."${masterKeyColumn}", d.id
|
|
||||||
`;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
|
||||||
|
|
||||||
const data = await query<any>(sql, params);
|
|
||||||
|
|
||||||
// 헤더 및 컬럼 정보 구성
|
|
||||||
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
|
||||||
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
headers,
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
masterColumns: masterColumns.map(c => c.name),
|
|
||||||
detailColumns: detailColumns.map(c => c.name),
|
|
||||||
joinKey: masterKeyColumn,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
|
||||||
*/
|
|
||||||
private async findForeignKeyColumn(
|
|
||||||
sourceTable: string,
|
|
||||||
referenceTable: string
|
|
||||||
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
|
||||||
try {
|
|
||||||
const result = await query<{ column_name: string; reference_column: string }>(
|
|
||||||
`SELECT column_name, reference_column
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND reference_table = $2
|
|
||||||
AND input_type = 'entity'
|
|
||||||
LIMIT 1`,
|
|
||||||
[sourceTable, referenceTable]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
return {
|
|
||||||
sourceColumn: result[0].column_name,
|
|
||||||
referenceColumn: result[0].reference_column,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
|
||||||
*
|
|
||||||
* 처리 로직:
|
|
||||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
|
||||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
|
||||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
|
||||||
* 4. 새 디테일 데이터 INSERT
|
|
||||||
*/
|
|
||||||
async uploadJoinedData(
|
|
||||||
relation: MasterDetailRelation,
|
|
||||||
data: Record<string, any>[],
|
|
||||||
companyCode: string,
|
|
||||||
userId?: string
|
|
||||||
): Promise<ExcelUploadResult> {
|
|
||||||
const result: ExcelUploadResult = {
|
|
||||||
success: false,
|
|
||||||
masterInserted: 0,
|
|
||||||
masterUpdated: 0,
|
|
||||||
detailInserted: 0,
|
|
||||||
detailDeleted: 0,
|
|
||||||
errors: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
|
||||||
|
|
||||||
// 1. 데이터를 마스터 키로 그룹화
|
|
||||||
const groupedData = new Map<string, Record<string, any>[]>();
|
|
||||||
|
|
||||||
for (const row of data) {
|
|
||||||
const masterKey = row[masterKeyColumn];
|
|
||||||
if (!masterKey) {
|
|
||||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!groupedData.has(masterKey)) {
|
|
||||||
groupedData.set(masterKey, []);
|
|
||||||
}
|
|
||||||
groupedData.get(masterKey)!.push(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
|
||||||
|
|
||||||
// 2. 각 그룹 처리
|
|
||||||
for (const [masterKey, rows] of groupedData.entries()) {
|
|
||||||
try {
|
|
||||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
|
||||||
const masterData: Record<string, any> = {};
|
|
||||||
for (const col of masterColumns) {
|
|
||||||
if (rows[0][col.name] !== undefined) {
|
|
||||||
masterData[col.name] = rows[0][col.name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회사 코드, 작성자 추가
|
|
||||||
masterData.company_code = companyCode;
|
|
||||||
if (userId) {
|
|
||||||
masterData.writer = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. 마스터 UPSERT
|
|
||||||
const existingMaster = await client.query(
|
|
||||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
|
||||||
[masterKey, companyCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingMaster.rows.length > 0) {
|
|
||||||
// UPDATE
|
|
||||||
const updateCols = Object.keys(masterData)
|
|
||||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
|
||||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
|
||||||
const updateValues = Object.keys(masterData)
|
|
||||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
|
||||||
.map(k => masterData[k]);
|
|
||||||
|
|
||||||
if (updateCols.length > 0) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE "${masterTable}"
|
|
||||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
|
||||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
|
||||||
[...updateValues, masterKey, companyCode]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result.masterUpdated++;
|
|
||||||
} else {
|
|
||||||
// INSERT
|
|
||||||
const insertCols = Object.keys(masterData);
|
|
||||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const insertValues = insertCols.map(k => masterData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
|
||||||
insertValues
|
|
||||||
);
|
|
||||||
result.masterInserted++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2c. 기존 디테일 삭제
|
|
||||||
const deleteResult = await client.query(
|
|
||||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
|
||||||
[masterKey, companyCode]
|
|
||||||
);
|
|
||||||
result.detailDeleted += deleteResult.rowCount || 0;
|
|
||||||
|
|
||||||
// 2d. 새 디테일 INSERT
|
|
||||||
for (const row of rows) {
|
|
||||||
const detailData: Record<string, any> = {};
|
|
||||||
|
|
||||||
// FK 컬럼 추가
|
|
||||||
detailData[detailFkColumn] = masterKey;
|
|
||||||
detailData.company_code = companyCode;
|
|
||||||
if (userId) {
|
|
||||||
detailData.writer = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디테일 컬럼 데이터 추출
|
|
||||||
for (const col of detailColumns) {
|
|
||||||
if (row[col.name] !== undefined) {
|
|
||||||
detailData[col.name] = row[col.name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertCols = Object.keys(detailData);
|
|
||||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const insertValues = insertCols.map(k => detailData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
|
||||||
insertValues
|
|
||||||
);
|
|
||||||
result.detailInserted++;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
masterUpdated: result.masterUpdated,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
detailDeleted: result.detailDeleted,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 간단 모드 업로드
|
|
||||||
*
|
|
||||||
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
|
||||||
* 채번 규칙을 통해 마스터 키 자동 생성
|
|
||||||
*
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @param detailData 디테일 데이터 배열
|
|
||||||
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
|
||||||
* @param numberingRuleId 채번 규칙 ID (optional)
|
|
||||||
* @param companyCode 회사 코드
|
|
||||||
* @param userId 사용자 ID
|
|
||||||
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
|
||||||
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
|
||||||
*/
|
|
||||||
async uploadSimple(
|
|
||||||
screenId: number,
|
|
||||||
detailData: Record<string, any>[],
|
|
||||||
masterFieldValues: Record<string, any>,
|
|
||||||
numberingRuleId: string | undefined,
|
|
||||||
companyCode: string,
|
|
||||||
userId: string,
|
|
||||||
afterUploadFlowId?: string,
|
|
||||||
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
detailInserted: number;
|
|
||||||
generatedKey: string;
|
|
||||||
errors: string[];
|
|
||||||
controlResult?: any;
|
|
||||||
}> {
|
|
||||||
const result: {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
detailInserted: number;
|
|
||||||
generatedKey: string;
|
|
||||||
errors: string[];
|
|
||||||
controlResult?: any;
|
|
||||||
} = {
|
|
||||||
success: false,
|
|
||||||
masterInserted: 0,
|
|
||||||
detailInserted: 0,
|
|
||||||
generatedKey: "",
|
|
||||||
errors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
const pool = getPool();
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
// 1. 마스터-디테일 관계 정보 조회
|
|
||||||
const relation = await this.getMasterDetailRelation(screenId);
|
|
||||||
if (!relation) {
|
|
||||||
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
|
||||||
|
|
||||||
// 2. 채번 처리
|
|
||||||
let generatedKey: string;
|
|
||||||
|
|
||||||
if (numberingRuleId) {
|
|
||||||
// 채번 규칙으로 키 생성
|
|
||||||
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
|
||||||
} else {
|
|
||||||
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
|
||||||
generatedKey = masterFieldValues[masterKeyColumn];
|
|
||||||
if (!generatedKey) {
|
|
||||||
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.generatedKey = generatedKey;
|
|
||||||
logger.info(`채번 결과: ${generatedKey}`);
|
|
||||||
|
|
||||||
// 3. 마스터 레코드 생성
|
|
||||||
const masterData: Record<string, any> = {
|
|
||||||
...masterFieldValues,
|
|
||||||
[masterKeyColumn]: generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
writer: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마스터 컬럼명 목록 구성
|
|
||||||
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
|
||||||
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const masterValues = masterCols.map(k => masterData[k]);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
|
||||||
masterValues
|
|
||||||
);
|
|
||||||
result.masterInserted = 1;
|
|
||||||
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
|
||||||
|
|
||||||
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
|
||||||
const insertedDetailRows: Record<string, any>[] = [];
|
|
||||||
|
|
||||||
for (const row of detailData) {
|
|
||||||
try {
|
|
||||||
const detailRowData: Record<string, any> = {
|
|
||||||
...row,
|
|
||||||
[detailFkColumn]: generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
writer: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 빈 값 필터링 및 id 제외
|
|
||||||
const detailCols = Object.keys(detailRowData).filter(k =>
|
|
||||||
k !== "id" &&
|
|
||||||
detailRowData[k] !== undefined &&
|
|
||||||
detailRowData[k] !== null &&
|
|
||||||
detailRowData[k] !== ""
|
|
||||||
);
|
|
||||||
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
|
||||||
const detailValues = detailCols.map(k => detailRowData[k]);
|
|
||||||
|
|
||||||
// RETURNING *로 삽입된 데이터 반환받기
|
|
||||||
const insertResult = await client.query(
|
|
||||||
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
|
||||||
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
|
||||||
RETURNING *`,
|
|
||||||
detailValues
|
|
||||||
);
|
|
||||||
|
|
||||||
if (insertResult.rows && insertResult.rows[0]) {
|
|
||||||
insertedDetailRows.push(insertResult.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.detailInserted++;
|
|
||||||
} catch (error: any) {
|
|
||||||
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
|
||||||
logger.error(`디테일 행 처리 실패:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
|
||||||
|
|
||||||
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
|
||||||
masterInserted: result.masterInserted,
|
|
||||||
detailInserted: result.detailInserted,
|
|
||||||
generatedKey: result.generatedKey,
|
|
||||||
errors: result.errors.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 업로드 후 제어 실행 (단일 또는 다중)
|
|
||||||
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
|
||||||
? afterUploadFlows // 다중 제어
|
|
||||||
: afterUploadFlowId
|
|
||||||
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (flowsToExecute.length > 0 && result.success) {
|
|
||||||
try {
|
|
||||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
|
||||||
|
|
||||||
// 마스터 데이터 구성
|
|
||||||
const masterData = {
|
|
||||||
...masterFieldValues,
|
|
||||||
[relation!.masterKeyColumn]: result.generatedKey,
|
|
||||||
company_code: companyCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
const controlResults: any[] = [];
|
|
||||||
|
|
||||||
// 순서대로 제어 실행
|
|
||||||
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
|
||||||
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
|
||||||
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
|
||||||
|
|
||||||
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
|
||||||
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
|
||||||
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
|
||||||
const controlResult = await NodeFlowExecutionService.executeFlow(
|
|
||||||
parseInt(flow.flowId),
|
|
||||||
{
|
|
||||||
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
|
||||||
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
|
||||||
buttonId: "excel-upload-button",
|
|
||||||
screenId: screenId,
|
|
||||||
userId: userId,
|
|
||||||
companyCode: companyCode,
|
|
||||||
formData: masterData,
|
|
||||||
// 추가 컨텍스트: 마스터/디테일 정보
|
|
||||||
masterData: masterData,
|
|
||||||
detailData: insertedDetailRows,
|
|
||||||
masterTable: relation!.masterTable,
|
|
||||||
detailTable: relation!.detailTable,
|
|
||||||
masterKeyColumn: relation!.masterKeyColumn,
|
|
||||||
detailFkColumn: relation!.detailFkColumn,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
controlResults.push({
|
|
||||||
flowId: flow.flowId,
|
|
||||||
order: flow.order,
|
|
||||||
success: controlResult.success,
|
|
||||||
message: controlResult.message,
|
|
||||||
executedNodes: controlResult.nodes?.length || 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
result.controlResult = {
|
|
||||||
success: controlResults.every(r => r.success),
|
|
||||||
executedFlows: controlResults.length,
|
|
||||||
results: controlResults,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
|
||||||
} catch (controlError: any) {
|
|
||||||
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
|
||||||
result.controlResult = {
|
|
||||||
success: false,
|
|
||||||
message: `제어 실행 실패: ${controlError.message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
|
||||||
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
|
||||||
*/
|
|
||||||
private async generateNumberWithRule(
|
|
||||||
client: any,
|
|
||||||
ruleId: string,
|
|
||||||
companyCode: string
|
|
||||||
): Promise<string> {
|
|
||||||
try {
|
|
||||||
// 기존 numberingRuleService를 사용하여 코드 할당
|
|
||||||
const { numberingRuleService } = await import("./numberingRuleService");
|
|
||||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
|
||||||
|
|
||||||
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
|
||||||
|
|
||||||
return generatedCode;
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const masterDetailExcelService = new MasterDetailExcelService();
|
|
||||||
|
|
||||||
|
|
@ -2090,7 +2090,7 @@ export class MenuCopyService {
|
||||||
menu.menu_url,
|
menu.menu_url,
|
||||||
menu.menu_desc,
|
menu.menu_desc,
|
||||||
userId,
|
userId,
|
||||||
'active', // 복제된 메뉴는 항상 활성화 상태
|
menu.status,
|
||||||
menu.system_name,
|
menu.system_name,
|
||||||
targetCompanyCode, // 새 회사 코드
|
targetCompanyCode, // 새 회사 코드
|
||||||
menu.lang_key,
|
menu.lang_key,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -969,56 +969,21 @@ export class NodeFlowExecutionService {
|
||||||
const insertedData = { ...data };
|
const insertedData = { ...data };
|
||||||
|
|
||||||
console.log("🗺️ 필드 매핑 처리 중...");
|
console.log("🗺️ 필드 매핑 처리 중...");
|
||||||
|
fieldMappings.forEach((mapping: any) => {
|
||||||
// 🔥 채번 규칙 서비스 동적 import
|
|
||||||
const { numberingRuleService } = await import("./numberingRuleService");
|
|
||||||
|
|
||||||
for (const mapping of fieldMappings) {
|
|
||||||
fields.push(mapping.targetField);
|
fields.push(mapping.targetField);
|
||||||
let value: any;
|
const value =
|
||||||
|
mapping.staticValue !== undefined
|
||||||
// 🔥 값 생성 유형에 따른 처리
|
? mapping.staticValue
|
||||||
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
: data[mapping.sourceField];
|
||||||
|
|
||||||
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
console.log(
|
||||||
// 자동 생성 (채번 규칙)
|
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||||
const companyCode = context.buttonContext?.companyCode || "*";
|
);
|
||||||
try {
|
|
||||||
value = await numberingRuleService.allocateCode(
|
|
||||||
mapping.numberingRuleId,
|
|
||||||
companyCode
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
|
||||||
console.error(
|
|
||||||
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
|
||||||
// 고정값
|
|
||||||
value = mapping.staticValue;
|
|
||||||
console.log(
|
|
||||||
` 📌 고정값: ${mapping.targetField} = ${value}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 소스 필드
|
|
||||||
value = data[mapping.sourceField];
|
|
||||||
console.log(
|
|
||||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(value);
|
values.push(value);
|
||||||
|
|
||||||
// 🔥 삽입된 값을 데이터에 반영
|
// 🔥 삽입된 값을 데이터에 반영
|
||||||
insertedData[mapping.targetField] = value;
|
insertedData[mapping.targetField] = value;
|
||||||
}
|
});
|
||||||
|
|
||||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||||
const hasWriterMapping = fieldMappings.some(
|
const hasWriterMapping = fieldMappings.some(
|
||||||
|
|
@ -1563,24 +1528,16 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
let finalWhereConditions: any[];
|
whereConditions,
|
||||||
if (whereConditions && whereConditions.length > 0) {
|
data,
|
||||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
targetTable
|
||||||
finalWhereConditions = whereConditions;
|
);
|
||||||
} else {
|
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
|
||||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
|
||||||
whereConditions,
|
|
||||||
data,
|
|
||||||
targetTable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
finalWhereConditions,
|
enhancedWhereConditions,
|
||||||
data,
|
data,
|
||||||
paramIndex
|
paramIndex
|
||||||
);
|
);
|
||||||
|
|
@ -1950,30 +1907,22 @@ export class NodeFlowExecutionService {
|
||||||
return deletedDataArray;
|
return deletedDataArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 context-data 모드: 개별 삭제
|
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
||||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||||
|
|
||||||
for (const data of dataArray) {
|
for (const data of dataArray) {
|
||||||
console.log("🔍 WHERE 조건 처리 중...");
|
console.log("🔍 WHERE 조건 처리 중...");
|
||||||
|
|
||||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||||
let finalWhereConditions: any[];
|
whereConditions,
|
||||||
if (whereConditions && whereConditions.length > 0) {
|
data,
|
||||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
targetTable
|
||||||
finalWhereConditions = whereConditions;
|
);
|
||||||
} else {
|
|
||||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
|
||||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
|
||||||
whereConditions,
|
|
||||||
data,
|
|
||||||
targetTable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereResult = this.buildWhereClause(
|
const whereResult = this.buildWhereClause(
|
||||||
finalWhereConditions,
|
enhancedWhereConditions,
|
||||||
data,
|
data,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -2916,11 +2865,10 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
||||||
);
|
);
|
||||||
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
||||||
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
return operator === "NOT_EXISTS_IN";
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -4446,8 +4394,6 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 산술 연산 계산
|
* 산술 연산 계산
|
||||||
* 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용
|
|
||||||
* 예: (width * height) / 1000000 * qty
|
|
||||||
*/
|
*/
|
||||||
private static evaluateArithmetic(
|
private static evaluateArithmetic(
|
||||||
arithmetic: any,
|
arithmetic: any,
|
||||||
|
|
@ -4474,67 +4420,27 @@ export class NodeFlowExecutionService {
|
||||||
const leftNum = Number(left) || 0;
|
const leftNum = Number(left) || 0;
|
||||||
const rightNum = Number(right) || 0;
|
const rightNum = Number(right) || 0;
|
||||||
|
|
||||||
// 기본 연산 수행
|
switch (arithmetic.operator) {
|
||||||
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 연산 처리 (다중 연산 지원)
|
|
||||||
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
|
|
||||||
for (const addOp of arithmetic.additionalOperations) {
|
|
||||||
const operandValue = this.getOperandValue(
|
|
||||||
addOp.operand,
|
|
||||||
sourceRow,
|
|
||||||
targetRow,
|
|
||||||
resultValues
|
|
||||||
);
|
|
||||||
const operandNum = Number(operandValue) || 0;
|
|
||||||
|
|
||||||
result = this.applyOperator(result, addOp.operator, operandNum);
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단일 연산자 적용
|
|
||||||
*/
|
|
||||||
private static applyOperator(
|
|
||||||
left: number,
|
|
||||||
operator: string,
|
|
||||||
right: number
|
|
||||||
): number | null {
|
|
||||||
switch (operator) {
|
|
||||||
case "+":
|
case "+":
|
||||||
return left + right;
|
return leftNum + rightNum;
|
||||||
case "-":
|
case "-":
|
||||||
return left - right;
|
return leftNum - rightNum;
|
||||||
case "*":
|
case "*":
|
||||||
return left * right;
|
return leftNum * rightNum;
|
||||||
case "/":
|
case "/":
|
||||||
if (right === 0) {
|
if (rightNum === 0) {
|
||||||
logger.warn(`⚠️ 0으로 나누기 시도`);
|
logger.warn(`⚠️ 0으로 나누기 시도`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return left / right;
|
return leftNum / rightNum;
|
||||||
case "%":
|
case "%":
|
||||||
if (right === 0) {
|
if (rightNum === 0) {
|
||||||
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return left % right;
|
return leftNum % rightNum;
|
||||||
default:
|
default:
|
||||||
throw new Error(`지원하지 않는 연산자: ${operator}`);
|
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2597,10 +2597,10 @@ export class ScreenManagementService {
|
||||||
// 없으면 원본과 같은 회사에 복사
|
// 없으면 원본과 같은 회사에 복사
|
||||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||||
|
|
||||||
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
|
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
||||||
const existingScreens = await client.query<any>(
|
const existingScreens = await client.query<any>(
|
||||||
`SELECT screen_id FROM screen_definitions
|
`SELECT screen_id FROM screen_definitions
|
||||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
WHERE screen_code = $1 AND company_code = $2
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[copyData.screenCode, targetCompanyCode]
|
[copyData.screenCode, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -187,68 +187,71 @@ class TableCategoryValueService {
|
||||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||||
let query: string;
|
let query: string;
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
const baseSelect = `
|
|
||||||
SELECT
|
|
||||||
value_id AS "valueId",
|
|
||||||
table_name AS "tableName",
|
|
||||||
column_name AS "columnName",
|
|
||||||
value_code AS "valueCode",
|
|
||||||
value_label AS "valueLabel",
|
|
||||||
value_order AS "valueOrder",
|
|
||||||
parent_value_id AS "parentValueId",
|
|
||||||
depth,
|
|
||||||
description,
|
|
||||||
color,
|
|
||||||
icon,
|
|
||||||
is_active AS "isActive",
|
|
||||||
is_default AS "isDefault",
|
|
||||||
company_code AS "companyCode",
|
|
||||||
menu_objid AS "menuObjid",
|
|
||||||
created_at AS "createdAt",
|
|
||||||
updated_at AS "updatedAt",
|
|
||||||
created_by AS "createdBy",
|
|
||||||
updated_by AS "updatedBy"
|
|
||||||
FROM table_column_category_values
|
|
||||||
WHERE table_name = $1
|
|
||||||
AND column_name = $2
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
// 최고 관리자: 모든 카테고리 값 조회
|
||||||
if (menuObjid && siblingObjids.length > 0) {
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
query = `
|
||||||
params = [tableName, columnName, siblingObjids];
|
SELECT
|
||||||
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
value_id AS "valueId",
|
||||||
} else if (menuObjid) {
|
table_name AS "tableName",
|
||||||
query = baseSelect + ` AND menu_objid = $3`;
|
column_name AS "columnName",
|
||||||
params = [tableName, columnName, menuObjid];
|
value_code AS "valueCode",
|
||||||
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
value_label AS "valueLabel",
|
||||||
} else {
|
value_order AS "valueOrder",
|
||||||
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
parent_value_id AS "parentValueId",
|
||||||
query = baseSelect;
|
depth,
|
||||||
params = [tableName, columnName];
|
description,
|
||||||
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
color,
|
||||||
}
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt",
|
||||||
|
created_by AS "createdBy",
|
||||||
|
updated_by AS "updatedBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName];
|
||||||
|
logger.info("최고 관리자 카테고리 값 조회");
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
// 일반 회사: 자신의 카테고리 값만 조회
|
||||||
if (menuObjid && siblingObjids.length > 0) {
|
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
|
||||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
query = `
|
||||||
params = [tableName, columnName, companyCode, siblingObjids];
|
SELECT
|
||||||
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
value_id AS "valueId",
|
||||||
} else if (menuObjid) {
|
table_name AS "tableName",
|
||||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
column_name AS "columnName",
|
||||||
params = [tableName, columnName, companyCode, menuObjid];
|
value_code AS "valueCode",
|
||||||
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
value_label AS "valueLabel",
|
||||||
} else {
|
value_order AS "valueOrder",
|
||||||
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
parent_value_id AS "parentValueId",
|
||||||
query = baseSelect + ` AND company_code = $3`;
|
depth,
|
||||||
params = [tableName, columnName, companyCode];
|
description,
|
||||||
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
color,
|
||||||
}
|
icon,
|
||||||
|
is_active AS "isActive",
|
||||||
|
is_default AS "isDefault",
|
||||||
|
company_code AS "companyCode",
|
||||||
|
menu_objid AS "menuObjid",
|
||||||
|
created_at AS "createdAt",
|
||||||
|
updated_at AS "updatedAt",
|
||||||
|
created_by AS "createdBy",
|
||||||
|
updated_by AS "updatedBy"
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND column_name = $2
|
||||||
|
AND company_code = $3
|
||||||
|
`;
|
||||||
|
params = [tableName, columnName, companyCode];
|
||||||
|
logger.info("회사별 카테고리 값 조회", { companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeInactive) {
|
if (!includeInactive) {
|
||||||
|
|
|
||||||
|
|
@ -1314,7 +1314,7 @@ export class TableManagementService {
|
||||||
// 각 값을 LIKE 또는 = 조건으로 처리
|
// 각 값을 LIKE 또는 = 조건으로 처리
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
|
|
||||||
value.forEach((v: any, idx: number) => {
|
value.forEach((v: any, idx: number) => {
|
||||||
const safeValue = String(v).trim();
|
const safeValue = String(v).trim();
|
||||||
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
||||||
|
|
@ -1323,24 +1323,17 @@ export class TableManagementService {
|
||||||
// - "2," 로 시작
|
// - "2," 로 시작
|
||||||
// - ",2" 로 끝남
|
// - ",2" 로 끝남
|
||||||
// - ",2," 중간에 포함
|
// - ",2," 중간에 포함
|
||||||
const paramBase = paramIndex + idx * 4;
|
const paramBase = paramIndex + (idx * 4);
|
||||||
conditions.push(`(
|
conditions.push(`(
|
||||||
${columnName}::text = $${paramBase} OR
|
${columnName}::text = $${paramBase} OR
|
||||||
${columnName}::text LIKE $${paramBase + 1} OR
|
${columnName}::text LIKE $${paramBase + 1} OR
|
||||||
${columnName}::text LIKE $${paramBase + 2} OR
|
${columnName}::text LIKE $${paramBase + 2} OR
|
||||||
${columnName}::text LIKE $${paramBase + 3}
|
${columnName}::text LIKE $${paramBase + 3}
|
||||||
)`);
|
)`);
|
||||||
values.push(
|
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
|
||||||
safeValue,
|
|
||||||
`${safeValue},%`,
|
|
||||||
`%,${safeValue}`,
|
|
||||||
`%,${safeValue},%`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
|
||||||
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
whereClause: `(${conditions.join(" OR ")})`,
|
whereClause: `(${conditions.join(" OR ")})`,
|
||||||
values,
|
values,
|
||||||
|
|
@ -1779,29 +1772,21 @@ export class TableManagementService {
|
||||||
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
||||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||||
const referenceTable = entityTypeInfo.referenceTable;
|
const referenceTable = entityTypeInfo.referenceTable;
|
||||||
|
|
||||||
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
||||||
let displayColumn = entityTypeInfo.displayColumn;
|
let displayColumn = entityTypeInfo.displayColumn;
|
||||||
if (
|
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
|
||||||
!displayColumn ||
|
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
|
||||||
displayColumn === "none" ||
|
|
||||||
displayColumn === ""
|
|
||||||
) {
|
|
||||||
displayColumn = await this.findDisplayColumnForTable(
|
|
||||||
referenceTable,
|
|
||||||
referenceColumn
|
|
||||||
);
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 참조 테이블의 표시 컬럼으로 검색
|
// 참조 테이블의 표시 컬럼으로 검색
|
||||||
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
|
|
||||||
return {
|
return {
|
||||||
whereClause: `EXISTS (
|
whereClause: `EXISTS (
|
||||||
SELECT 1 FROM ${referenceTable} ref
|
SELECT 1 FROM ${referenceTable} ref
|
||||||
WHERE ref.${referenceColumn} = main.${columnName}
|
WHERE ref.${referenceColumn} = ${columnName}
|
||||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||||
)`,
|
)`,
|
||||||
values: [`%${value}%`],
|
values: [`%${value}%`],
|
||||||
|
|
@ -2165,14 +2150,14 @@ export class TableManagementService {
|
||||||
// 안전한 테이블명 검증
|
// 안전한 테이블명 검증
|
||||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||||
|
|
||||||
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
|
// 전체 개수 조회
|
||||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
||||||
const countResult = await query<any>(countQuery, searchValues);
|
const countResult = await query<any>(countQuery, searchValues);
|
||||||
const total = parseInt(countResult[0].count);
|
const total = parseInt(countResult[0].count);
|
||||||
|
|
||||||
// 데이터 조회 (main 별칭 추가)
|
// 데이터 조회
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT main.* FROM ${safeTableName} main
|
SELECT * FROM ${safeTableName}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
${orderClause}
|
${orderClause}
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
|
@ -2509,7 +2494,7 @@ export class TableManagementService {
|
||||||
skippedColumns.push(column);
|
skippedColumns.push(column);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataType = columnTypeMap.get(column) || "text";
|
const dataType = columnTypeMap.get(column) || "text";
|
||||||
setConditions.push(
|
setConditions.push(
|
||||||
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
||||||
|
|
@ -2521,9 +2506,7 @@ export class TableManagementService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (skippedColumns.length > 0) {
|
if (skippedColumns.length > 0) {
|
||||||
logger.info(
|
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
|
||||||
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
||||||
|
|
@ -2728,12 +2711,6 @@ export class TableManagementService {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
deduplication?: {
|
|
||||||
enabled: boolean;
|
|
||||||
groupByColumn: string;
|
|
||||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
|
||||||
sortColumn?: string;
|
|
||||||
}; // 🆕 중복 제거 설정
|
|
||||||
}
|
}
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
@ -2784,74 +2761,33 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const additionalColumn of options.additionalJoinColumns) {
|
for (const additionalColumn of options.additionalJoinColumns) {
|
||||||
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
||||||
let baseJoinConfig = joinConfigs.find(
|
const baseJoinConfig = joinConfigs.find(
|
||||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
|
||||||
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
|
||||||
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
|
||||||
baseJoinConfig = joinConfigs.find(
|
|
||||||
(config) =>
|
|
||||||
config.referenceTable ===
|
|
||||||
(additionalColumn as any).referenceTable
|
|
||||||
);
|
|
||||||
if (baseJoinConfig) {
|
|
||||||
logger.info(
|
|
||||||
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseJoinConfig) {
|
if (baseJoinConfig) {
|
||||||
// joinAlias에서 실제 컬럼명 추출
|
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
||||||
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
||||||
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
||||||
|
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
||||||
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
||||||
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
|
||||||
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
|
||||||
let actualColumnName: string;
|
|
||||||
|
|
||||||
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
|
||||||
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
|
||||||
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
|
||||||
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
|
||||||
actualColumnName = originalJoinAlias.replace(
|
|
||||||
`${frontendSourceColumn}_`,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
|
||||||
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
|
||||||
actualColumnName = originalJoinAlias.replace(
|
|
||||||
`${sourceColumn}_`,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// 어느 것도 아니면 원본 사용
|
|
||||||
actualColumnName = originalJoinAlias;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
|
||||||
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
|
||||||
|
|
||||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||||
sourceColumn,
|
sourceColumn,
|
||||||
frontendSourceColumn,
|
joinAlias,
|
||||||
originalJoinAlias,
|
|
||||||
correctedJoinAlias,
|
|
||||||
actualColumnName,
|
actualColumnName,
|
||||||
referenceTable: (additionalColumn as any).referenceTable,
|
referenceTable: additionalColumn.sourceTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||||
const isBasicEntityJoin =
|
const isBasicEntityJoin =
|
||||||
correctedJoinAlias === `${sourceColumn}_name`;
|
additionalColumn.joinAlias ===
|
||||||
|
`${baseJoinConfig.sourceColumn}_name`;
|
||||||
|
|
||||||
if (isBasicEntityJoin) {
|
if (isBasicEntityJoin) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
||||||
);
|
);
|
||||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
@ -2859,14 +2795,14 @@ export class TableManagementService {
|
||||||
// 추가 조인 컬럼 설정 생성
|
// 추가 조인 컬럼 설정 생성
|
||||||
const additionalJoinConfig: EntityJoinConfig = {
|
const additionalJoinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
||||||
referenceTable:
|
referenceTable:
|
||||||
(additionalColumn as any).referenceTable ||
|
(additionalColumn as any).referenceTable ||
|
||||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
||||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
||||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
||||||
displayColumn: actualColumnName, // 하위 호환성
|
displayColumn: actualColumnName, // 하위 호환성
|
||||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
||||||
separator: " - ", // 기본 구분자
|
separator: " - ", // 기본 구분자
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -3226,10 +3162,8 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
||||||
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
|
|
||||||
const allEntityColumns = [
|
const allEntityColumns = [
|
||||||
...joinConfigs.map((config) => config.aliasColumn),
|
...joinConfigs.map((config) => config.aliasColumn),
|
||||||
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
|
|
||||||
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
||||||
...joinConfigs.flatMap((config) => {
|
...joinConfigs.flatMap((config) => {
|
||||||
const additionalColumns = [];
|
const additionalColumns = [];
|
||||||
|
|
@ -3635,10 +3569,8 @@ export class TableManagementService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// main. 접두사 추가 (조인 쿼리용)
|
// main. 접두사 추가 (조인 쿼리용)
|
||||||
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
|
|
||||||
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
|
|
||||||
condition = condition.replace(
|
condition = condition.replace(
|
||||||
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
|
new RegExp(`\\b${columnName}\\b`, "g"),
|
||||||
`main.${columnName}`
|
`main.${columnName}`
|
||||||
);
|
);
|
||||||
conditions.push(condition);
|
conditions.push(condition);
|
||||||
|
|
@ -3840,12 +3772,9 @@ export class TableManagementService {
|
||||||
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||||
const companySpecificTables = [
|
const companySpecificTables = [
|
||||||
"supplier_mng",
|
"supplier_mng",
|
||||||
"customer_mng",
|
"customer_mng",
|
||||||
"item_info",
|
"item_info",
|
||||||
"dept_info",
|
"dept_info",
|
||||||
"sales_order_mng", // 🔧 수주관리 테이블 추가
|
|
||||||
"sales_order_detail", // 🔧 수주상세 테이블 추가
|
|
||||||
"partner_info", // 🔧 거래처 테이블 추가
|
|
||||||
// 필요시 추가
|
// 필요시 추가
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -4756,7 +4685,7 @@ export class TableManagementService {
|
||||||
/**
|
/**
|
||||||
* 두 테이블 간의 엔티티 관계 자동 감지
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
||||||
*
|
*
|
||||||
* @param leftTable 좌측 테이블명
|
* @param leftTable 좌측 테이블명
|
||||||
* @param rightTable 우측 테이블명
|
* @param rightTable 우측 테이블명
|
||||||
* @returns 감지된 엔티티 관계 배열
|
* @returns 감지된 엔티티 관계 배열
|
||||||
|
|
@ -4764,20 +4693,16 @@ export class TableManagementService {
|
||||||
async detectTableEntityRelations(
|
async detectTableEntityRelations(
|
||||||
leftTable: string,
|
leftTable: string,
|
||||||
rightTable: string
|
rightTable: string
|
||||||
): Promise<
|
): Promise<Array<{
|
||||||
Array<{
|
leftColumn: string;
|
||||||
leftColumn: string;
|
rightColumn: string;
|
||||||
rightColumn: string;
|
direction: "left_to_right" | "right_to_left";
|
||||||
direction: "left_to_right" | "right_to_left";
|
inputType: string;
|
||||||
inputType: string;
|
displayColumn?: string;
|
||||||
displayColumn?: string;
|
}>> {
|
||||||
}>
|
|
||||||
> {
|
|
||||||
try {
|
try {
|
||||||
logger.info(
|
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
|
||||||
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const relations: Array<{
|
const relations: Array<{
|
||||||
leftColumn: string;
|
leftColumn: string;
|
||||||
rightColumn: string;
|
rightColumn: string;
|
||||||
|
|
@ -4844,17 +4769,12 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
||||||
relations.forEach((rel, idx) => {
|
relations.forEach((rel, idx) => {
|
||||||
logger.info(
|
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
|
||||||
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return relations;
|
return relations;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
|
||||||
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,30 +17,12 @@ export interface LangKey {
|
||||||
langKey: string;
|
langKey: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isActive: string;
|
isActive: string;
|
||||||
categoryId?: number;
|
|
||||||
keyMeaning?: string;
|
|
||||||
usageNote?: string;
|
|
||||||
baseKeyId?: number;
|
|
||||||
createdDate?: Date;
|
createdDate?: Date;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
updatedDate?: Date;
|
updatedDate?: Date;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 인터페이스
|
|
||||||
export interface LangCategory {
|
|
||||||
categoryId: number;
|
|
||||||
categoryCode: string;
|
|
||||||
categoryName: string;
|
|
||||||
parentId?: number | null;
|
|
||||||
level: number;
|
|
||||||
keyPrefix: string;
|
|
||||||
description?: string;
|
|
||||||
sortOrder: number;
|
|
||||||
isActive: string;
|
|
||||||
children?: LangCategory[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LangText {
|
export interface LangText {
|
||||||
textId?: number;
|
textId?: number;
|
||||||
keyId: number;
|
keyId: number;
|
||||||
|
|
@ -81,38 +63,10 @@ export interface CreateLangKeyRequest {
|
||||||
langKey: string;
|
langKey: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isActive?: string;
|
isActive?: string;
|
||||||
categoryId?: number;
|
|
||||||
keyMeaning?: string;
|
|
||||||
usageNote?: string;
|
|
||||||
baseKeyId?: number;
|
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자동 키 생성 요청
|
|
||||||
export interface GenerateKeyRequest {
|
|
||||||
companyCode: string;
|
|
||||||
categoryId: number;
|
|
||||||
keyMeaning: string;
|
|
||||||
usageNote?: string;
|
|
||||||
texts: Array<{
|
|
||||||
langCode: string;
|
|
||||||
langText: string;
|
|
||||||
}>;
|
|
||||||
createdBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오버라이드 키 생성 요청
|
|
||||||
export interface CreateOverrideKeyRequest {
|
|
||||||
companyCode: string;
|
|
||||||
baseKeyId: number;
|
|
||||||
texts: Array<{
|
|
||||||
langCode: string;
|
|
||||||
langText: string;
|
|
||||||
}>;
|
|
||||||
createdBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateLangKeyRequest {
|
export interface UpdateLangKeyRequest {
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
menuName?: string;
|
menuName?: string;
|
||||||
|
|
@ -136,8 +90,6 @@ export interface GetLangKeysParams {
|
||||||
menuCode?: string;
|
menuCode?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
categoryId?: number;
|
|
||||||
includeOverrides?: boolean;
|
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -588,4 +588,3 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 | 최초 작성 |
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -361,4 +361,3 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -1,127 +1,68 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
|
|
||||||
import ScreenList from "@/components/screen/ScreenList";
|
import ScreenList from "@/components/screen/ScreenList";
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
import TemplateManager from "@/components/screen/TemplateManager";
|
||||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
|
||||||
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
|
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
|
||||||
|
|
||||||
// 단계별 진행을 위한 타입 정의
|
// 단계별 진행을 위한 타입 정의
|
||||||
type Step = "list" | "design" | "template";
|
type Step = "list" | "design" | "template";
|
||||||
type ViewMode = "tree" | "table";
|
|
||||||
|
|
||||||
export default function ScreenManagementPage() {
|
export default function ScreenManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
|
||||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
|
||||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
|
||||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
||||||
|
|
||||||
// 화면 목록 로드
|
|
||||||
const loadScreens = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
|
|
||||||
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
|
|
||||||
if (result.data && result.data.length > 0) {
|
|
||||||
setScreens(result.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("화면 목록 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadScreens();
|
|
||||||
}, [loadScreens]);
|
|
||||||
|
|
||||||
// 화면 목록 새로고침 이벤트 리스너
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScreenListRefresh = () => {
|
|
||||||
console.log("🔄 화면 목록 새로고침 이벤트 수신");
|
|
||||||
loadScreens();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
|
|
||||||
};
|
|
||||||
}, [loadScreens]);
|
|
||||||
|
|
||||||
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
|
||||||
useEffect(() => {
|
|
||||||
const openDesignerId = searchParams.get("openDesigner");
|
|
||||||
if (openDesignerId && screens.length > 0) {
|
|
||||||
const screenId = parseInt(openDesignerId, 10);
|
|
||||||
const targetScreen = screens.find((s) => s.screenId === screenId);
|
|
||||||
if (targetScreen) {
|
|
||||||
setSelectedScreen(targetScreen);
|
|
||||||
setCurrentStep("design");
|
|
||||||
setStepHistory(["list", "design"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, screens]);
|
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 전체 화면 사용
|
// 화면 설계 모드일 때는 전체 화면 사용
|
||||||
const isDesignMode = currentStep === "design";
|
const isDesignMode = currentStep === "design";
|
||||||
|
|
||||||
|
// 단계별 제목과 설명
|
||||||
|
const stepConfig = {
|
||||||
|
list: {
|
||||||
|
title: "화면 목록 관리",
|
||||||
|
description: "생성된 화면들을 확인하고 관리하세요",
|
||||||
|
},
|
||||||
|
design: {
|
||||||
|
title: "화면 설계",
|
||||||
|
description: "드래그앤드롭으로 화면을 설계하세요",
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: "템플릿 관리",
|
||||||
|
description: "화면 템플릿을 관리하고 재사용하세요",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 다음 단계로 이동
|
// 다음 단계로 이동
|
||||||
const goToNextStep = (nextStep: Step) => {
|
const goToNextStep = (nextStep: Step) => {
|
||||||
setStepHistory((prev) => [...prev, nextStep]);
|
setStepHistory((prev) => [...prev, nextStep]);
|
||||||
setCurrentStep(nextStep);
|
setCurrentStep(nextStep);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 이전 단계로 이동
|
||||||
|
const goToPreviousStep = () => {
|
||||||
|
if (stepHistory.length > 1) {
|
||||||
|
const newHistory = stepHistory.slice(0, -1);
|
||||||
|
const previousStep = newHistory[newHistory.length - 1];
|
||||||
|
setStepHistory(newHistory);
|
||||||
|
setCurrentStep(previousStep);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 특정 단계로 이동
|
// 특정 단계로 이동
|
||||||
const goToStep = (step: Step) => {
|
const goToStep = (step: Step) => {
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
|
// 해당 단계까지의 히스토리만 유지
|
||||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||||
if (stepIndex !== -1) {
|
if (stepIndex !== -1) {
|
||||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
||||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
|
||||||
setSelectedScreen(screen);
|
|
||||||
setSelectedGroup(null); // 그룹 선택 해제
|
|
||||||
};
|
|
||||||
|
|
||||||
// 화면 디자인 핸들러
|
|
||||||
const handleDesignScreen = (screen: ScreenDefinition) => {
|
|
||||||
setSelectedScreen(screen);
|
|
||||||
goToNextStep("design");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색어로 필터링된 화면
|
|
||||||
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
|
|
||||||
// 단일 키워드면 해당 키워드로 화면 필터링
|
|
||||||
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
||||||
const filteredScreens = searchKeywords.length > 1
|
|
||||||
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
|
||||||
: screens.filter((screen) =>
|
|
||||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
|
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-background">
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
|
|
@ -131,119 +72,59 @@ export default function ScreenManagementPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
{/* 페이지 헤더 */}
|
<div className="space-y-6 p-6">
|
||||||
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<div>
|
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
||||||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
{/* 단계별 내용 */}
|
||||||
{/* 뷰 모드 전환 */}
|
<div className="flex-1">
|
||||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
{/* 화면 목록 단계 */}
|
||||||
<TabsList className="h-9">
|
{currentStep === "list" && (
|
||||||
<TabsTrigger value="tree" className="gap-1.5 px-3">
|
<ScreenList
|
||||||
<LayoutGrid className="h-4 w-4" />
|
onScreenSelect={setSelectedScreen}
|
||||||
트리
|
selectedScreen={selectedScreen}
|
||||||
</TabsTrigger>
|
onDesignScreen={(screen) => {
|
||||||
<TabsTrigger value="table" className="gap-1.5 px-3">
|
setSelectedScreen(screen);
|
||||||
<LayoutList className="h-4 w-4" />
|
goToNextStep("design");
|
||||||
테이블
|
}}
|
||||||
</TabsTrigger>
|
/>
|
||||||
</TabsList>
|
)}
|
||||||
</Tabs>
|
|
||||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
{/* 템플릿 관리 단계 */}
|
||||||
<RefreshCw className="h-4 w-4" />
|
{currentStep === "template" && (
|
||||||
</Button>
|
<div className="space-y-6">
|
||||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<Plus className="h-4 w-4" />
|
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
|
||||||
새 화면
|
<div className="flex gap-2">
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
|
onClick={goToPreviousStep}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
이전 단계
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => goToStep("list")}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
목록으로 돌아가기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
|
||||||
{viewMode === "tree" ? (
|
|
||||||
<div className="flex-1 overflow-hidden flex">
|
|
||||||
{/* 왼쪽: 트리 구조 */}
|
|
||||||
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
|
|
||||||
{/* 검색 */}
|
|
||||||
<div className="flex-shrink-0 p-3 border-b">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="화면 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 트리 뷰 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ScreenGroupTreeView
|
|
||||||
screens={filteredScreens}
|
|
||||||
selectedScreen={selectedScreen}
|
|
||||||
onScreenSelect={handleScreenSelect}
|
|
||||||
onScreenDesign={handleDesignScreen}
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
onGroupSelect={(group) => {
|
|
||||||
setSelectedGroup(group);
|
|
||||||
setSelectedScreen(null); // 화면 선택 해제
|
|
||||||
setFocusedScreenIdInGroup(null); // 포커스 초기화
|
|
||||||
}}
|
|
||||||
onScreenSelectInGroup={(group, screenId) => {
|
|
||||||
// 그룹 내 화면 클릭 시
|
|
||||||
const isNewGroup = selectedGroup?.id !== group.id;
|
|
||||||
|
|
||||||
if (isNewGroup) {
|
|
||||||
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
|
|
||||||
setSelectedGroup(group);
|
|
||||||
setFocusedScreenIdInGroup(null);
|
|
||||||
} else {
|
|
||||||
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
|
|
||||||
setFocusedScreenIdInGroup(screenId);
|
|
||||||
}
|
|
||||||
setSelectedScreen(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ScreenRelationFlow
|
|
||||||
screen={selectedScreen}
|
|
||||||
selectedGroup={selectedGroup}
|
|
||||||
initialFocusedScreenId={focusedScreenIdInGroup}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 테이블 뷰 (기존 ScreenList 사용)
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
<ScreenList
|
|
||||||
onScreenSelect={handleScreenSelect}
|
|
||||||
selectedScreen={selectedScreen}
|
|
||||||
onDesignScreen={handleDesignScreen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 화면 생성 모달 */}
|
|
||||||
<CreateScreenModal
|
|
||||||
isOpen={isCreateOpen}
|
|
||||||
onClose={() => setIsCreateOpen(false)}
|
|
||||||
onSuccess={() => {
|
|
||||||
setIsCreateOpen(false);
|
|
||||||
loadScreens();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scroll to Top 버튼 */}
|
{/* Scroll to Top 버튼 */}
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,13 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
|
|
||||||
import { DataTable } from "@/components/common/DataTable";
|
import { DataTable } from "@/components/common/DataTable";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import LangKeyModal from "@/components/admin/LangKeyModal";
|
import LangKeyModal from "@/components/admin/LangKeyModal";
|
||||||
import LanguageModal from "@/components/admin/LanguageModal";
|
import LanguageModal from "@/components/admin/LanguageModal";
|
||||||
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
|
|
||||||
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { LangCategory } from "@/lib/api/multilang";
|
|
||||||
|
|
||||||
interface Language {
|
interface Language {
|
||||||
langCode: string;
|
langCode: string;
|
||||||
|
|
@ -35,7 +29,6 @@ interface LangKey {
|
||||||
langKey: string;
|
langKey: string;
|
||||||
description: string;
|
description: string;
|
||||||
isActive: string;
|
isActive: string;
|
||||||
categoryId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LangText {
|
interface LangText {
|
||||||
|
|
@ -66,10 +59,6 @@ export default function I18nPage() {
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
|
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
|
||||||
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
|
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
|
||||||
|
|
||||||
// 카테고리 관련 상태
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
|
|
||||||
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
|
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
|
||||||
|
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
|
|
@ -103,14 +92,9 @@ export default function I18nPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 다국어 키 목록 조회
|
// 다국어 키 목록 조회
|
||||||
const fetchLangKeys = async (categoryId?: number | null) => {
|
const fetchLangKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const response = await apiClient.get("/multilang/keys");
|
||||||
if (categoryId) {
|
|
||||||
params.append("categoryId", categoryId.toString());
|
|
||||||
}
|
|
||||||
const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`;
|
|
||||||
const response = await apiClient.get(url);
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setLangKeys(data.data);
|
setLangKeys(data.data);
|
||||||
|
|
@ -487,13 +471,6 @@ export default function I18nPage() {
|
||||||
initializeData();
|
initializeData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 카테고리 변경 시 키 목록 다시 조회
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading) {
|
|
||||||
fetchLangKeys(selectedCategory?.categoryId);
|
|
||||||
}
|
|
||||||
}, [selectedCategory?.categoryId]);
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
id: "select",
|
id: "select",
|
||||||
|
|
@ -701,70 +678,27 @@ export default function I18nPage() {
|
||||||
|
|
||||||
{/* 다국어 키 관리 탭 */}
|
{/* 다국어 키 관리 탭 */}
|
||||||
{activeTab === "keys" && (
|
{activeTab === "keys" && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
||||||
{/* 좌측: 카테고리 트리 (2/12) */}
|
{/* 좌측: 언어 키 목록 (7/10) */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-7">
|
||||||
<CardHeader className="py-3">
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm">카테고리</CardTitle>
|
<CardTitle>언어 키 목록</CardTitle>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<ScrollArea className="h-[500px]">
|
|
||||||
<CategoryTree
|
|
||||||
selectedCategoryId={selectedCategory?.categoryId || null}
|
|
||||||
onSelectCategory={(cat) => setSelectedCategory(cat)}
|
|
||||||
onDoubleClickCategory={(cat) => {
|
|
||||||
setSelectedCategory(cat);
|
|
||||||
setIsGenerateModalOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 중앙: 언어 키 목록 (6/12) */}
|
|
||||||
<Card className="lg:col-span-6">
|
|
||||||
<CardHeader className="py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm">
|
|
||||||
언어 키 목록
|
|
||||||
{selectedCategory && (
|
|
||||||
<Badge variant="secondary" className="ml-2">
|
|
||||||
{selectedCategory.categoryName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteSelectedKeys}
|
|
||||||
disabled={selectedKeys.size === 0}
|
|
||||||
>
|
|
||||||
선택 삭제 ({selectedKeys.size})
|
선택 삭제 ({selectedKeys.size})
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={handleAddKey}>
|
<Button onClick={handleAddKey}>새 키 추가</Button>
|
||||||
수동 추가
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsGenerateModalOpen(true)}
|
|
||||||
disabled={!selectedCategory}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
|
||||||
자동 생성
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent>
|
||||||
{/* 검색 필터 영역 */}
|
{/* 검색 필터 영역 */}
|
||||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="company" className="text-xs">회사</Label>
|
<Label htmlFor="company">회사</Label>
|
||||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="전체 회사" />
|
<SelectValue placeholder="전체 회사" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -779,22 +713,22 @@ export default function I18nPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="search" className="text-xs">검색</Label>
|
<Label htmlFor="search">검색</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="키명, 설명으로 검색..."
|
placeholder="키명, 설명, 메뉴, 회사로 검색..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<div className="text-xs text-muted-foreground">결과: {getFilteredLangKeys().length}건</div>
|
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div>
|
<div>
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={getFilteredLangKeys()}
|
data={getFilteredLangKeys()}
|
||||||
|
|
@ -805,8 +739,8 @@ export default function I18nPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
|
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
|
||||||
<Card className="lg:col-span-4">
|
<Card className="lg:col-span-3">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{selectedKey ? (
|
{selectedKey ? (
|
||||||
|
|
@ -883,18 +817,6 @@ export default function I18nPage() {
|
||||||
onSave={handleSaveLanguage}
|
onSave={handleSaveLanguage}
|
||||||
languageData={editingLanguage}
|
languageData={editingLanguage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 키 자동 생성 모달 */}
|
|
||||||
<KeyGenerateModal
|
|
||||||
isOpen={isGenerateModalOpen}
|
|
||||||
onClose={() => setIsGenerateModalOpen(false)}
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
companyCode={user?.companyCode || ""}
|
|
||||||
isSuperAdmin={user?.companyCode === "*"}
|
|
||||||
onSuccess={() => {
|
|
||||||
fetchLangKeys(selectedCategory?.categoryId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,8 @@ function ScreenViewPage() {
|
||||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||||
|
|
||||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
|
||||||
const previewCompanyCode = searchParams.get("company_code");
|
|
||||||
|
|
||||||
// 프리뷰 모드 감지 (iframe에서 로드될 때)
|
|
||||||
const isPreviewMode = searchParams.get("preview") === "true";
|
|
||||||
|
|
||||||
// 🆕 현재 로그인한 사용자 정보
|
// 🆕 현재 로그인한 사용자 정보
|
||||||
const { user, userName, companyCode: authCompanyCode } = useAuth();
|
const { user, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
|
|
||||||
const companyCode = previewCompanyCode || authCompanyCode;
|
|
||||||
|
|
||||||
// 🆕 모바일 환경 감지
|
// 🆕 모바일 환경 감지
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
@ -113,7 +104,7 @@ function ScreenViewPage() {
|
||||||
// 편집 모달 이벤트 리스너 등록
|
// 편집 모달 이벤트 리스너 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenEditModal = (event: CustomEvent) => {
|
const handleOpenEditModal = (event: CustomEvent) => {
|
||||||
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||||
|
|
||||||
setEditModalConfig({
|
setEditModalConfig({
|
||||||
screenId: event.detail.screenId,
|
screenId: event.detail.screenId,
|
||||||
|
|
@ -242,40 +233,27 @@ function ScreenViewPage() {
|
||||||
const designWidth = layout?.screenResolution?.width || 1200;
|
const designWidth = layout?.screenResolution?.width || 1200;
|
||||||
const designHeight = layout?.screenResolution?.height || 800;
|
const designHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
|
// 컨테이너의 실제 크기
|
||||||
let containerWidth: number;
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
let containerHeight: number;
|
const containerHeight = containerRef.current.offsetHeight;
|
||||||
|
|
||||||
if (isPreviewMode) {
|
|
||||||
// iframe에서는 window 크기를 직접 사용
|
|
||||||
containerWidth = window.innerWidth;
|
|
||||||
containerHeight = window.innerHeight;
|
|
||||||
} else {
|
|
||||||
containerWidth = containerRef.current.offsetWidth;
|
|
||||||
containerHeight = containerRef.current.offsetHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newScale: number;
|
// 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8)
|
||||||
|
|
||||||
if (isPreviewMode) {
|
|
||||||
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
|
|
||||||
const scaleX = containerWidth / designWidth;
|
|
||||||
const scaleY = containerHeight / designHeight;
|
|
||||||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
|
||||||
} else {
|
|
||||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
|
||||||
const MARGIN_X = 32;
|
const MARGIN_X = 32;
|
||||||
const availableWidth = containerWidth - MARGIN_X;
|
const availableWidth = containerWidth - MARGIN_X;
|
||||||
newScale = availableWidth / designWidth;
|
|
||||||
}
|
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
||||||
|
const newScale = availableWidth / designWidth;
|
||||||
|
|
||||||
// console.log("📐 스케일 계산:", {
|
// console.log("📐 스케일 계산:", {
|
||||||
// containerWidth,
|
// containerWidth,
|
||||||
// containerHeight,
|
// containerHeight,
|
||||||
|
// MARGIN_X,
|
||||||
|
// availableWidth,
|
||||||
// designWidth,
|
// designWidth,
|
||||||
// designHeight,
|
// designHeight,
|
||||||
// finalScale: newScale,
|
// finalScale: newScale,
|
||||||
// isPreviewMode,
|
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||||
|
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
|
|
@ -294,7 +272,7 @@ function ScreenViewPage() {
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [layout, isMobile, isPreviewMode]);
|
}, [layout, isMobile]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -332,7 +310,7 @@ function ScreenViewPage() {
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div ref={containerRef} className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}>
|
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
|
|
|
||||||
|
|
@ -388,18 +388,226 @@ select {
|
||||||
border-spacing: 0 !important;
|
border-spacing: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 저장 테이블 막대기 애니메이션 ===== */
|
/* ===== POP (Production Operation Panel) Styles ===== */
|
||||||
@keyframes saveBarDrop {
|
|
||||||
0% {
|
/* POP 전용 다크 테마 변수 */
|
||||||
transform: scaleY(0);
|
.pop-dark {
|
||||||
transform-origin: top;
|
/* 배경 색상 */
|
||||||
opacity: 0;
|
--pop-bg-deepest: 8 12 21;
|
||||||
}
|
--pop-bg-deep: 10 15 28;
|
||||||
|
--pop-bg-primary: 13 19 35;
|
||||||
|
--pop-bg-secondary: 18 26 47;
|
||||||
|
--pop-bg-tertiary: 25 35 60;
|
||||||
|
--pop-bg-elevated: 32 45 75;
|
||||||
|
|
||||||
|
/* 네온 강조색 */
|
||||||
|
--pop-neon-cyan: 0 212 255;
|
||||||
|
--pop-neon-cyan-bright: 0 240 255;
|
||||||
|
--pop-neon-cyan-dim: 0 150 190;
|
||||||
|
--pop-neon-pink: 255 0 102;
|
||||||
|
--pop-neon-purple: 138 43 226;
|
||||||
|
|
||||||
|
/* 상태 색상 */
|
||||||
|
--pop-success: 0 255 136;
|
||||||
|
--pop-success-dim: 0 180 100;
|
||||||
|
--pop-warning: 255 170 0;
|
||||||
|
--pop-warning-dim: 200 130 0;
|
||||||
|
--pop-danger: 255 51 51;
|
||||||
|
--pop-danger-dim: 200 40 40;
|
||||||
|
|
||||||
|
/* 텍스트 색상 */
|
||||||
|
--pop-text-primary: 255 255 255;
|
||||||
|
--pop-text-secondary: 180 195 220;
|
||||||
|
--pop-text-muted: 100 120 150;
|
||||||
|
|
||||||
|
/* 테두리 색상 */
|
||||||
|
--pop-border: 40 55 85;
|
||||||
|
--pop-border-light: 55 75 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 전용 라이트 테마 변수 */
|
||||||
|
.pop-light {
|
||||||
|
--pop-bg-deepest: 245 247 250;
|
||||||
|
--pop-bg-deep: 240 243 248;
|
||||||
|
--pop-bg-primary: 250 251 253;
|
||||||
|
--pop-bg-secondary: 255 255 255;
|
||||||
|
--pop-bg-tertiary: 245 247 250;
|
||||||
|
--pop-bg-elevated: 235 238 245;
|
||||||
|
|
||||||
|
--pop-neon-cyan: 0 122 204;
|
||||||
|
--pop-neon-cyan-bright: 0 140 230;
|
||||||
|
--pop-neon-cyan-dim: 0 100 170;
|
||||||
|
--pop-neon-pink: 220 38 127;
|
||||||
|
--pop-neon-purple: 118 38 200;
|
||||||
|
|
||||||
|
--pop-success: 22 163 74;
|
||||||
|
--pop-success-dim: 21 128 61;
|
||||||
|
--pop-warning: 245 158 11;
|
||||||
|
--pop-warning-dim: 217 119 6;
|
||||||
|
--pop-danger: 220 38 38;
|
||||||
|
--pop-danger-dim: 185 28 28;
|
||||||
|
|
||||||
|
--pop-text-primary: 15 23 42;
|
||||||
|
--pop-text-secondary: 71 85 105;
|
||||||
|
--pop-text-muted: 148 163 184;
|
||||||
|
|
||||||
|
--pop-border: 226 232 240;
|
||||||
|
--pop-border-light: 203 213 225;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 배경 그리드 패턴 */
|
||||||
|
.pop-bg-pattern::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-light .pop-bg-pattern::before {
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 글로우 효과 */
|
||||||
|
.pop-glow-cyan {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-cyan-strong {
|
||||||
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-success {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-warning {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-danger {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 펄스 글로우 애니메이션 */
|
||||||
|
@keyframes pop-pulse-glow {
|
||||||
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: scaleY(1);
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
|
||||||
transform-origin: top;
|
}
|
||||||
opacity: 1;
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pop-animate-pulse-glow {
|
||||||
|
animation: pop-pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 프로그레스 바 샤인 애니메이션 */
|
||||||
|
@keyframes pop-progress-shine {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-progress-shine::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
|
||||||
|
animation: pop-progress-shine 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 스타일 */
|
||||||
|
.pop-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgb(var(--pop-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--pop-border-light));
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--pop-neon-cyan-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 숨기기 */
|
||||||
|
.pop-hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Marching Ants Animation (Excel Copy Border) ===== */
|
||||||
|
@keyframes marching-ants-h {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marching-ants-v {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-marching-ants-h {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--primary)) 0,
|
||||||
|
hsl(var(--primary)) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
background-size: 16px 2px;
|
||||||
|
animation: marching-ants-h 0.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-marching-ants-v {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
180deg,
|
||||||
|
hsl(var(--primary)) 0,
|
||||||
|
hsl(var(--primary)) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
background-size: 2px 16px;
|
||||||
|
animation: marching-ants-v 0.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,35 +34,6 @@ import { cn } from "@/lib/utils";
|
||||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||||
|
|
||||||
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
|
||||||
export interface MasterDetailExcelConfig {
|
|
||||||
// 테이블 정보
|
|
||||||
masterTable?: string;
|
|
||||||
detailTable?: string;
|
|
||||||
masterKeyColumn?: string;
|
|
||||||
detailFkColumn?: string;
|
|
||||||
// 채번
|
|
||||||
numberingRuleId?: string;
|
|
||||||
// 업로드 전 사용자가 선택할 마스터 테이블 필드
|
|
||||||
masterSelectFields?: Array<{
|
|
||||||
columnName: string;
|
|
||||||
columnLabel: string;
|
|
||||||
required: boolean;
|
|
||||||
inputType: "entity" | "date" | "text" | "select";
|
|
||||||
referenceTable?: string;
|
|
||||||
referenceColumn?: string;
|
|
||||||
displayColumn?: string;
|
|
||||||
}>;
|
|
||||||
// 엑셀에서 매핑할 디테일 테이블 필드
|
|
||||||
detailExcelFields?: Array<{
|
|
||||||
columnName: string;
|
|
||||||
columnLabel: string;
|
|
||||||
required: boolean;
|
|
||||||
}>;
|
|
||||||
masterDefaults?: Record<string, any>;
|
|
||||||
detailDefaults?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExcelUploadModalProps {
|
export interface ExcelUploadModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -71,24 +42,6 @@ export interface ExcelUploadModalProps {
|
||||||
keyColumn?: string;
|
keyColumn?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
// 마스터-디테일 지원
|
|
||||||
screenId?: number;
|
|
||||||
isMasterDetail?: boolean;
|
|
||||||
masterDetailRelation?: {
|
|
||||||
masterTable: string;
|
|
||||||
detailTable: string;
|
|
||||||
masterKeyColumn: string;
|
|
||||||
detailFkColumn: string;
|
|
||||||
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
|
||||||
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
|
||||||
};
|
|
||||||
// 🆕 마스터-디테일 엑셀 업로드 설정
|
|
||||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
|
||||||
// 🆕 단일 테이블 채번 설정
|
|
||||||
numberingRuleId?: string;
|
|
||||||
numberingTargetColumn?: string;
|
|
||||||
// 🆕 업로드 후 제어 실행 설정
|
|
||||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnMapping {
|
interface ColumnMapping {
|
||||||
|
|
@ -104,15 +57,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
keyColumn,
|
keyColumn,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
userId = "guest",
|
userId = "guest",
|
||||||
screenId,
|
|
||||||
isMasterDetail = false,
|
|
||||||
masterDetailRelation,
|
|
||||||
masterDetailExcelConfig,
|
|
||||||
// 단일 테이블 채번 설정
|
|
||||||
numberingRuleId,
|
|
||||||
numberingTargetColumn,
|
|
||||||
// 업로드 후 제어 실행 설정
|
|
||||||
afterUploadFlows,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
|
||||||
|
|
@ -135,116 +79,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
// 3단계: 확인
|
// 3단계: 확인
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
|
||||||
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
|
|
||||||
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
|
|
||||||
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
|
|
||||||
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// 🆕 엔티티 참조 데이터 로드
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("🔍 엔티티 데이터 로드 체크:", {
|
|
||||||
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
|
|
||||||
open,
|
|
||||||
isMasterDetail,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!masterDetailExcelConfig?.masterSelectFields) return;
|
|
||||||
|
|
||||||
const loadEntityData = async () => {
|
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
|
||||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
|
||||||
|
|
||||||
for (const field of masterDetailExcelConfig.masterSelectFields!) {
|
|
||||||
console.log("🔍 필드 처리:", field);
|
|
||||||
|
|
||||||
if (field.inputType === "entity") {
|
|
||||||
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
|
|
||||||
try {
|
|
||||||
let refTable = field.referenceTable;
|
|
||||||
console.log("🔍 초기 refTable:", refTable);
|
|
||||||
|
|
||||||
let displayCol = field.displayColumn;
|
|
||||||
|
|
||||||
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
|
|
||||||
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
|
|
||||||
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
|
|
||||||
const colResponse = await apiClient.get(
|
|
||||||
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
|
|
||||||
);
|
|
||||||
console.log("🔍 컬럼 조회 응답:", colResponse.data);
|
|
||||||
|
|
||||||
if (colResponse.data?.success && colResponse.data?.data?.columns) {
|
|
||||||
const colInfo = colResponse.data.data.columns.find(
|
|
||||||
(c: any) => (c.columnName || c.column_name) === field.columnName
|
|
||||||
);
|
|
||||||
console.log("🔍 찾은 컬럼 정보:", colInfo);
|
|
||||||
if (colInfo) {
|
|
||||||
if (!refTable) {
|
|
||||||
refTable = colInfo.referenceTable || colInfo.reference_table;
|
|
||||||
console.log("🔍 DB에서 가져온 refTable:", refTable);
|
|
||||||
}
|
|
||||||
if (!displayCol) {
|
|
||||||
displayCol = colInfo.displayColumn || colInfo.display_column;
|
|
||||||
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayColumn 저장 (Select 렌더링 시 사용)
|
|
||||||
if (displayCol) {
|
|
||||||
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refTable) {
|
|
||||||
console.log("🔍 엔티티 데이터 조회:", refTable);
|
|
||||||
const response = await DynamicFormApi.getTableData(refTable, {
|
|
||||||
page: 1,
|
|
||||||
pageSize: 1000,
|
|
||||||
});
|
|
||||||
console.log("🔍 엔티티 데이터 응답:", response);
|
|
||||||
// getTableData는 { success, data: [...] } 형식으로 반환
|
|
||||||
const rows = response.data?.rows || response.data;
|
|
||||||
if (response.success && rows && Array.isArray(rows)) {
|
|
||||||
setEntitySearchData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field.columnName]: rows,
|
|
||||||
}));
|
|
||||||
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
|
|
||||||
} finally {
|
|
||||||
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
|
|
||||||
loadEntityData();
|
|
||||||
}
|
|
||||||
}, [open, isMasterDetail, masterDetailExcelConfig]);
|
|
||||||
|
|
||||||
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
|
|
||||||
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
|
|
||||||
const hasMasterSelectFields = isSimpleMasterDetailMode &&
|
|
||||||
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
// 마스터 필드가 모두 입력되었는지 확인
|
|
||||||
const isMasterFieldsValid = () => {
|
|
||||||
if (!hasMasterSelectFields) return true;
|
|
||||||
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
|
|
||||||
if (!field.required) return true;
|
|
||||||
const value = masterFieldValues[field.columnName];
|
|
||||||
return value !== undefined && value !== null && value !== "";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 파일 선택 핸들러
|
// 파일 선택 핸들러
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selectedFile = e.target.files?.[0];
|
const selectedFile = e.target.files?.[0];
|
||||||
|
|
@ -350,138 +184,50 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
const loadTableSchema = async () => {
|
const loadTableSchema = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
|
console.log("🔍 테이블 스키마 로드 시작:", { tableName });
|
||||||
|
|
||||||
let allColumns: TableColumn[] = [];
|
const response = await getTableSchema(tableName);
|
||||||
|
|
||||||
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
|
console.log("📊 테이블 스키마 응답:", response);
|
||||||
if (isSimpleMasterDetailMode && masterDetailRelation) {
|
|
||||||
const { detailTable, detailFkColumn } = masterDetailRelation;
|
|
||||||
|
|
||||||
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
|
|
||||||
|
|
||||||
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
|
if (response.success && response.data) {
|
||||||
const detailResponse = await getTableSchema(detailTable);
|
// 자동 생성 컬럼 제외
|
||||||
if (detailResponse.success && detailResponse.data) {
|
const filteredColumns = response.data.columns.filter(
|
||||||
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
|
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||||
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
|
);
|
||||||
|
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
||||||
const detailCols = detailResponse.data.columns
|
setSystemColumns(filteredColumns);
|
||||||
.filter((col) => {
|
|
||||||
// 자동 생성 컬럼, FK 컬럼 제외
|
|
||||||
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
|
|
||||||
if (col.name === detailFkColumn) return false;
|
|
||||||
|
|
||||||
// 설정된 필드가 있으면 해당 필드만
|
|
||||||
if (configuredFields && configuredFields.length > 0) {
|
|
||||||
return configuredFields.some((f) => f.columnName === col.name);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((col) => {
|
|
||||||
// 설정에서 라벨 찾기
|
|
||||||
const configField = configuredFields?.find((f) => f.columnName === col.name);
|
|
||||||
return {
|
|
||||||
...col,
|
|
||||||
label: configField?.columnLabel || col.label || col.name,
|
|
||||||
originalName: col.name,
|
|
||||||
sourceTable: detailTable,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
allColumns = detailCols;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
|
// 기존 매핑 템플릿 조회
|
||||||
}
|
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
||||||
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
|
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
||||||
else if (isMasterDetail && masterDetailRelation) {
|
|
||||||
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
|
|
||||||
|
|
||||||
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
|
|
||||||
|
|
||||||
// 마스터 테이블 스키마
|
if (mappingResponse.success && mappingResponse.data) {
|
||||||
const masterResponse = await getTableSchema(masterTable);
|
// 저장된 매핑 템플릿이 있으면 자동 적용
|
||||||
if (masterResponse.success && masterResponse.data) {
|
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
||||||
const masterCols = masterResponse.data.columns
|
const savedMappings = mappingResponse.data.columnMappings;
|
||||||
.filter((col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()))
|
|
||||||
.map((col) => ({
|
|
||||||
...col,
|
|
||||||
// 유니크 키를 위해 테이블명 접두사 추가
|
|
||||||
name: `${masterTable}.${col.name}`,
|
|
||||||
label: `[마스터] ${col.label || col.name}`,
|
|
||||||
originalName: col.name,
|
|
||||||
sourceTable: masterTable,
|
|
||||||
}));
|
|
||||||
allColumns = [...allColumns, ...masterCols];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 디테일 테이블 스키마 (FK 컬럼 제외)
|
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
const detailResponse = await getTableSchema(detailTable);
|
excelColumn: col,
|
||||||
if (detailResponse.success && detailResponse.data) {
|
systemColumn: savedMappings[col] || null,
|
||||||
const detailCols = detailResponse.data.columns
|
}));
|
||||||
.filter((col) =>
|
setColumnMappings(appliedMappings);
|
||||||
!AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) &&
|
setIsAutoMappingLoaded(true);
|
||||||
col.name !== detailFkColumn // FK 컬럼 제외
|
|
||||||
)
|
|
||||||
.map((col) => ({
|
|
||||||
...col,
|
|
||||||
// 유니크 키를 위해 테이블명 접두사 추가
|
|
||||||
name: `${detailTable}.${col.name}`,
|
|
||||||
label: `[디테일] ${col.label || col.name}`,
|
|
||||||
originalName: col.name,
|
|
||||||
sourceTable: detailTable,
|
|
||||||
}));
|
|
||||||
allColumns = [...allColumns, ...detailCols];
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ 마스터-디테일 컬럼 로드 완료:", allColumns.length);
|
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
||||||
} else {
|
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
||||||
// 기존 단일 테이블 모드
|
|
||||||
const response = await getTableSchema(tableName);
|
|
||||||
|
|
||||||
console.log("📊 테이블 스키마 응답:", response);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
// 자동 생성 컬럼 제외
|
|
||||||
allColumns = response.data.columns.filter(
|
|
||||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", response);
|
// 매핑 템플릿이 없으면 초기 상태로 설정
|
||||||
return;
|
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
||||||
|
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||||
|
excelColumn: col,
|
||||||
|
systemColumn: null,
|
||||||
|
}));
|
||||||
|
setColumnMappings(initialMappings);
|
||||||
|
setIsAutoMappingLoaded(false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
|
|
||||||
setSystemColumns(allColumns);
|
|
||||||
|
|
||||||
// 기존 매핑 템플릿 조회
|
|
||||||
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
|
|
||||||
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
|
|
||||||
|
|
||||||
if (mappingResponse.success && mappingResponse.data) {
|
|
||||||
// 저장된 매핑 템플릿이 있으면 자동 적용
|
|
||||||
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
|
|
||||||
const savedMappings = mappingResponse.data.columnMappings;
|
|
||||||
|
|
||||||
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
|
||||||
excelColumn: col,
|
|
||||||
systemColumn: savedMappings[col] || null,
|
|
||||||
}));
|
|
||||||
setColumnMappings(appliedMappings);
|
|
||||||
setIsAutoMappingLoaded(true);
|
|
||||||
|
|
||||||
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
|
|
||||||
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
|
|
||||||
} else {
|
} else {
|
||||||
// 매핑 템플릿이 없으면 초기 상태로 설정
|
console.error("❌ 테이블 스키마 로드 실패:", response);
|
||||||
console.log("ℹ️ 매핑 템플릿 없음 - 새 엑셀 구조");
|
|
||||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
|
||||||
excelColumn: col,
|
|
||||||
systemColumn: null,
|
|
||||||
}));
|
|
||||||
setColumnMappings(initialMappings);
|
|
||||||
setIsAutoMappingLoaded(false);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 스키마 로드 실패:", error);
|
console.error("❌ 테이블 스키마 로드 실패:", error);
|
||||||
|
|
@ -493,35 +239,18 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const handleAutoMapping = () => {
|
const handleAutoMapping = () => {
|
||||||
const newMappings = excelColumns.map((excelCol) => {
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||||
// [마스터], [디테일] 접두사 제거 후 비교
|
|
||||||
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
|
|
||||||
|
|
||||||
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
|
// 1. 먼저 라벨로 매칭 시도
|
||||||
let matchedSystemCol = systemColumns.find((sysCol) => {
|
let matchedSystemCol = systemColumns.find(
|
||||||
if (!sysCol.label) return false;
|
(sysCol) =>
|
||||||
// [마스터], [디테일] 접두사 제거 후 비교
|
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
||||||
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
|
);
|
||||||
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||||
if (!matchedSystemCol) {
|
if (!matchedSystemCol) {
|
||||||
matchedSystemCol = systemColumns.find((sysCol) => {
|
matchedSystemCol = systemColumns.find(
|
||||||
// 마스터-디테일 모드: originalName이 있으면 사용
|
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
||||||
const originalName = (sysCol as any).originalName;
|
);
|
||||||
const colName = originalName || sysCol.name;
|
|
||||||
return colName.toLowerCase().trim() === normalizedExcelCol || colName.toLowerCase().trim() === cleanExcelCol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 여전히 매칭 안되면 전체 이름(테이블.컬럼)에서 컬럼 부분만 추출해서 비교
|
|
||||||
if (!matchedSystemCol) {
|
|
||||||
matchedSystemCol = systemColumns.find((sysCol) => {
|
|
||||||
// 테이블.컬럼 형식에서 컬럼만 추출
|
|
||||||
const nameParts = sysCol.name.split(".");
|
|
||||||
const colNameOnly = nameParts.length > 1 ? nameParts[1] : nameParts[0];
|
|
||||||
return colNameOnly.toLowerCase().trim() === normalizedExcelCol || colNameOnly.toLowerCase().trim() === cleanExcelCol;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -556,12 +285,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
|
|
||||||
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
|
|
||||||
toast.error("마스터 정보를 모두 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
// 빈 헤더가 아닌 열만 필터링
|
// 빈 헤더가 아닌 열만 필터링
|
||||||
|
|
@ -621,12 +344,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const mappedRow: Record<string, any> = {};
|
const mappedRow: Record<string, any> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
if (mapping.systemColumn) {
|
if (mapping.systemColumn) {
|
||||||
// 마스터-디테일 모드: 테이블.컬럼 형식에서 컬럼명만 추출
|
mappedRow[mapping.systemColumn] = row[mapping.excelColumn];
|
||||||
let colName = mapping.systemColumn;
|
|
||||||
if (isMasterDetail && colName.includes(".")) {
|
|
||||||
colName = colName.split(".")[1];
|
|
||||||
}
|
|
||||||
mappedRow[colName] = row[mapping.excelColumn];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return mappedRow;
|
return mappedRow;
|
||||||
|
|
@ -646,133 +364,60 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
let successCount = 0;
|
||||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
let failCount = 0;
|
||||||
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
|
||||||
masterDetailRelation,
|
|
||||||
masterFieldValues,
|
|
||||||
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
for (const row of filteredData) {
|
||||||
screenId,
|
try {
|
||||||
filteredData,
|
if (uploadMode === "insert") {
|
||||||
masterFieldValues,
|
const formData = { screenId: 0, tableName, data: row };
|
||||||
masterDetailExcelConfig?.numberingRuleId || undefined,
|
const result = await DynamicFormApi.saveFormData(formData);
|
||||||
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
if (result.success) {
|
||||||
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
successCount++;
|
||||||
);
|
} else {
|
||||||
|
failCount++;
|
||||||
if (uploadResult.success && uploadResult.data) {
|
}
|
||||||
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
|
}
|
||||||
|
} catch (error) {
|
||||||
toast.success(
|
failCount++;
|
||||||
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
|
|
||||||
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
|
||||||
);
|
|
||||||
|
|
||||||
// 매핑 템플릿 저장
|
|
||||||
await saveMappingTemplateInternal();
|
|
||||||
|
|
||||||
onSuccess?.();
|
|
||||||
} else {
|
|
||||||
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 🆕 마스터-디테일 기존 모드 처리
|
|
||||||
else if (isMasterDetail && screenId && masterDetailRelation) {
|
|
||||||
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
|
||||||
|
|
||||||
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
if (successCount > 0) {
|
||||||
screenId,
|
toast.success(
|
||||||
filteredData
|
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (uploadResult.success && uploadResult.data) {
|
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
|
||||||
const { masterInserted, masterUpdated, detailInserted, errors } = uploadResult.data;
|
try {
|
||||||
|
const mappingsToSave: Record<string, string | null> = {};
|
||||||
toast.success(
|
columnMappings.forEach((mapping) => {
|
||||||
`마스터 ${masterInserted + masterUpdated}건, 디테일 ${detailInserted}건 처리되었습니다.` +
|
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
||||||
(errors.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
});
|
||||||
|
|
||||||
|
console.log("💾 매핑 템플릿 저장 중...", {
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave,
|
||||||
|
});
|
||||||
|
const saveResult = await saveMappingTemplate(
|
||||||
|
tableName,
|
||||||
|
excelColumns,
|
||||||
|
mappingsToSave
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매핑 템플릿 저장
|
if (saveResult.success) {
|
||||||
await saveMappingTemplateInternal();
|
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
||||||
|
} else {
|
||||||
onSuccess?.();
|
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
||||||
} else {
|
}
|
||||||
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
} catch (error) {
|
||||||
|
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
// 기존 단일 테이블 업로드 로직
|
toast.error("업로드에 실패했습니다.");
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
// 단일 테이블 채번 설정 확인
|
|
||||||
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
|
||||||
|
|
||||||
for (const row of filteredData) {
|
|
||||||
try {
|
|
||||||
let dataToSave = { ...row };
|
|
||||||
|
|
||||||
// 채번 적용: 각 행마다 채번 API 호출
|
|
||||||
if (hasNumbering && uploadMode === "insert") {
|
|
||||||
try {
|
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
|
||||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
|
||||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
|
||||||
if (numberingResponse.data?.success && generatedCode) {
|
|
||||||
dataToSave[numberingTargetColumn] = generatedCode;
|
|
||||||
}
|
|
||||||
} catch (numError) {
|
|
||||||
console.error("채번 오류:", numError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadMode === "insert") {
|
|
||||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
|
||||||
const result = await DynamicFormApi.saveFormData(formData);
|
|
||||||
if (result.success) {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 업로드 후 제어 실행
|
|
||||||
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
|
|
||||||
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
|
|
||||||
try {
|
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
|
||||||
// 순서대로 실행
|
|
||||||
const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
|
|
||||||
for (const flow of sortedFlows) {
|
|
||||||
await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
|
|
||||||
sourceData: { tableName, uploadedCount: successCount },
|
|
||||||
});
|
|
||||||
console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
|
|
||||||
}
|
|
||||||
} catch (controlError) {
|
|
||||||
console.error("제어 실행 오류:", controlError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(
|
|
||||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 매핑 템플릿 저장
|
|
||||||
await saveMappingTemplateInternal();
|
|
||||||
|
|
||||||
onSuccess?.();
|
|
||||||
} else {
|
|
||||||
toast.error("업로드에 실패했습니다.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 업로드 실패:", error);
|
console.error("❌ 엑셀 업로드 실패:", error);
|
||||||
|
|
@ -782,35 +427,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 매핑 템플릿 저장 헬퍼 함수
|
|
||||||
const saveMappingTemplateInternal = async () => {
|
|
||||||
try {
|
|
||||||
const mappingsToSave: Record<string, string | null> = {};
|
|
||||||
columnMappings.forEach((mapping) => {
|
|
||||||
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("💾 매핑 템플릿 저장 중...", {
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave,
|
|
||||||
});
|
|
||||||
const saveResult = await saveMappingTemplate(
|
|
||||||
tableName,
|
|
||||||
excelColumns,
|
|
||||||
mappingsToSave
|
|
||||||
);
|
|
||||||
|
|
||||||
if (saveResult.success) {
|
|
||||||
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
|
|
||||||
} else {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모달 닫기 시 초기화
|
// 모달 닫기 시 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -825,8 +441,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setExcelColumns([]);
|
setExcelColumns([]);
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
// 🆕 마스터-디테일 모드 초기화
|
|
||||||
setMasterFieldValues({});
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
@ -847,21 +461,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
엑셀 데이터 업로드
|
엑셀 데이터 업로드
|
||||||
{isMasterDetail && (
|
|
||||||
<span className="ml-2 rounded bg-blue-100 px-2 py-0.5 text-xs font-normal text-blue-700">
|
|
||||||
마스터-디테일
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
{isMasterDetail && masterDetailRelation ? (
|
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
||||||
<>
|
|
||||||
마스터({masterDetailRelation.masterTable}) + 디테일({masterDetailRelation.detailTable}) 구조입니다.
|
|
||||||
마스터 데이터는 중복 입력 시 병합됩니다.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요."
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -916,87 +518,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
|
|
||||||
{hasMasterSelectFields && (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
|
|
||||||
<div key={field.columnName} className="space-y-1">
|
|
||||||
<Label className="text-xs">
|
|
||||||
{field.columnLabel}
|
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
|
||||||
</Label>
|
|
||||||
{field.inputType === "entity" ? (
|
|
||||||
<Select
|
|
||||||
value={masterFieldValues[field.columnName]?.toString() || ""}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setMasterFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field.columnName]: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 text-xs">
|
|
||||||
<SelectValue placeholder={`${field.columnLabel} 선택`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{entitySearchLoading[field.columnName] ? (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
로딩 중...
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
entitySearchData[field.columnName]?.map((item: any) => {
|
|
||||||
const keyValue = item[field.referenceColumn || "id"];
|
|
||||||
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
|
|
||||||
const displayColName =
|
|
||||||
field.displayColumn ||
|
|
||||||
entityDisplayColumns[field.columnName] ||
|
|
||||||
field.referenceColumn ||
|
|
||||||
"id";
|
|
||||||
const displayValue = item[displayColName] || keyValue;
|
|
||||||
return (
|
|
||||||
<SelectItem
|
|
||||||
key={keyValue}
|
|
||||||
value={keyValue?.toString()}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{displayValue}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : field.inputType === "date" ? (
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={masterFieldValues[field.columnName] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setMasterFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field.columnName]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-9 w-full rounded-md border px-3 text-xs"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={masterFieldValues[field.columnName] || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setMasterFieldValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[field.columnName]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={field.columnLabel}
|
|
||||||
className="h-9 w-full rounded-md border px-3 text-xs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 파일 선택 영역 */}
|
{/* 파일 선택 영역 */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -175,21 +175,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
if (editData) {
|
if (editData) {
|
||||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||||
|
|
||||||
// 🆕 배열인 경우 두 가지 데이터를 설정:
|
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
|
||||||
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
|
|
||||||
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
|
|
||||||
if (Array.isArray(editData)) {
|
if (Array.isArray(editData)) {
|
||||||
const firstRecord = editData[0] || {};
|
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
|
||||||
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
|
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
|
||||||
formData: "첫 번째 레코드 (일반 입력 필드용)",
|
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
|
||||||
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
|
|
||||||
});
|
|
||||||
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
|
|
||||||
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
|
|
||||||
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
|
|
||||||
} else {
|
} else {
|
||||||
setFormData(editData);
|
setFormData(editData);
|
||||||
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
|
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,6 @@ const nodeTypes = {
|
||||||
*/
|
*/
|
||||||
interface FlowEditorInnerProps {
|
interface FlowEditorInnerProps {
|
||||||
initialFlowId?: number | null;
|
initialFlowId?: number | null;
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
|
||||||
/** 임베디드 모드 여부 */
|
|
||||||
embedded?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 플로우 에디터 툴바 버튼 설정
|
// 플로우 에디터 툴바 버튼 설정
|
||||||
|
|
@ -91,7 +87,7 @@ const flowToolbarButtons: ToolbarButton[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
|
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||||
|
|
||||||
|
|
@ -389,7 +385,7 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
|
|
||||||
{/* 상단 툴바 */}
|
{/* 상단 툴바 */}
|
||||||
<Panel position="top-center" className="pointer-events-auto">
|
<Panel position="top-center" className="pointer-events-auto">
|
||||||
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
|
<FlowToolbar validations={validations} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -420,21 +416,13 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
||||||
*/
|
*/
|
||||||
interface FlowEditorProps {
|
interface FlowEditorProps {
|
||||||
initialFlowId?: number | null;
|
initialFlowId?: number | null;
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
|
||||||
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
|
|
||||||
embedded?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
|
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<FlowEditorInner
|
<FlowEditorInner initialFlowId={initialFlowId} />
|
||||||
initialFlowId={initialFlowId}
|
|
||||||
onSaveComplete={onSaveComplete}
|
|
||||||
embedded={embedded}
|
|
||||||
/>
|
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,9 @@ import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
interface FlowToolbarProps {
|
interface FlowToolbarProps {
|
||||||
validations?: FlowValidation[];
|
validations?: FlowValidation[];
|
||||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
|
||||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
|
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
const {
|
const {
|
||||||
|
|
@ -61,27 +59,13 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
||||||
const result = await saveFlow();
|
const result = await saveFlow();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast({
|
toast({
|
||||||
title: "저장 완료",
|
title: "✅ 플로우 저장 완료",
|
||||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
||||||
variant: "default",
|
variant: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 임베디드 모드에서 저장 완료 콜백 호출
|
|
||||||
if (onSaveComplete && result.flowId) {
|
|
||||||
onSaveComplete(result.flowId, flowName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
|
|
||||||
if (window.opener && result.flowId) {
|
|
||||||
window.opener.postMessage({
|
|
||||||
type: "FLOW_SAVED",
|
|
||||||
flowId: result.flowId,
|
|
||||||
flowName: flowName,
|
|
||||||
}, "*");
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
title: "저장 실패",
|
title: "❌ 저장 실패",
|
||||||
description: result.message,
|
description: result.message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,6 @@ const OPERATOR_LABELS: Record<string, string> = {
|
||||||
"%": "%",
|
"%": "%",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 피연산자를 문자열로 변환
|
|
||||||
function getOperandStr(operand: any): string {
|
|
||||||
if (!operand) return "?";
|
|
||||||
if (operand.type === "static") return String(operand.value || "?");
|
|
||||||
if (operand.fieldLabel) return operand.fieldLabel;
|
|
||||||
return operand.field || operand.resultField || "?";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수식 요약 생성
|
// 수식 요약 생성
|
||||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||||
|
|
@ -43,19 +35,11 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
|
||||||
switch (formulaType) {
|
switch (formulaType) {
|
||||||
case "arithmetic": {
|
case "arithmetic": {
|
||||||
if (!arithmetic) return "미설정";
|
if (!arithmetic) return "미설정";
|
||||||
const leftStr = getOperandStr(arithmetic.leftOperand);
|
const left = arithmetic.leftOperand;
|
||||||
const rightStr = getOperandStr(arithmetic.rightOperand);
|
const right = arithmetic.rightOperand;
|
||||||
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
|
||||||
|
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
|
||||||
// 추가 연산 표시
|
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||||
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
|
|
||||||
for (const addOp of arithmetic.additionalOperations) {
|
|
||||||
const opStr = getOperandStr(addOp.operand);
|
|
||||||
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return formula;
|
|
||||||
}
|
}
|
||||||
case "function": {
|
case "function": {
|
||||||
if (!func) return "미설정";
|
if (!func) return "미설정";
|
||||||
|
|
|
||||||
|
|
@ -797,85 +797,6 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
|
||||||
index,
|
index,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 추가 연산 목록 */}
|
|
||||||
{trans.arithmetic.additionalOperations && trans.arithmetic.additionalOperations.length > 0 && (
|
|
||||||
<div className="space-y-2 border-t pt-2">
|
|
||||||
<Label className="text-xs text-gray-500">추가 연산</Label>
|
|
||||||
{trans.arithmetic.additionalOperations.map((addOp: any, addIndex: number) => (
|
|
||||||
<div key={addIndex} className="flex items-center gap-2 rounded bg-orange-50 p-2">
|
|
||||||
<Select
|
|
||||||
value={addOp.operator}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
|
||||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operator: value };
|
|
||||||
handleTransformationChange(index, {
|
|
||||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-20 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ARITHMETIC_OPERATORS.map((op) => (
|
|
||||||
<SelectItem key={op.value} value={op.value}>
|
|
||||||
{op.value}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="flex-1">
|
|
||||||
{renderOperandSelector(
|
|
||||||
addOp.operand,
|
|
||||||
(updates) => {
|
|
||||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
|
||||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operand: updates };
|
|
||||||
handleTransformationChange(index, {
|
|
||||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
index,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
|
||||||
onClick={() => {
|
|
||||||
const newAdditionalOps = trans.arithmetic!.additionalOperations!.filter(
|
|
||||||
(_: any, i: number) => i !== addIndex
|
|
||||||
);
|
|
||||||
handleTransformationChange(index, {
|
|
||||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 추가 연산 버튼 */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-full text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
const newAdditionalOps = [
|
|
||||||
...(trans.arithmetic!.additionalOperations || []),
|
|
||||||
{ operator: "*", operand: { type: "static" as const, value: "" } },
|
|
||||||
];
|
|
||||||
handleTransformationChange(index, {
|
|
||||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
연산 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -18,8 +18,6 @@ import { cn } from "@/lib/utils";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||||
import { getNumberingRules } from "@/lib/api/numberingRule";
|
|
||||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
|
||||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||||
|
|
||||||
|
|
@ -91,11 +89,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||||
|
|
||||||
// 🔥 채번 규칙 관련 상태
|
|
||||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
|
||||||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
|
||||||
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
|
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || data.targetTable);
|
setDisplayName(data.displayName || data.targetTable);
|
||||||
|
|
@ -135,33 +128,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||||
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
|
|
||||||
}, [fieldMappings.length]);
|
}, [fieldMappings.length]);
|
||||||
|
|
||||||
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
|
|
||||||
useEffect(() => {
|
|
||||||
const loadNumberingRules = async () => {
|
|
||||||
setNumberingRulesLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getNumberingRules();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setNumberingRules(response.data);
|
|
||||||
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
|
|
||||||
} else {
|
|
||||||
console.error("❌ 채번 규칙 로딩 실패:", response.error);
|
|
||||||
setNumberingRules([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 채번 규칙 로딩 오류:", error);
|
|
||||||
setNumberingRules([]);
|
|
||||||
} finally {
|
|
||||||
setNumberingRulesLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadNumberingRules();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||||
|
|
@ -572,7 +540,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
sourceField: null,
|
sourceField: null,
|
||||||
targetField: "",
|
targetField: "",
|
||||||
staticValue: undefined,
|
staticValue: undefined,
|
||||||
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
setFieldMappings(newMappings);
|
setFieldMappings(newMappings);
|
||||||
|
|
@ -581,7 +548,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열 초기화
|
// Combobox 열림 상태 배열 초기화
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMapping = (index: number) => {
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
|
@ -592,7 +558,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// Combobox 열림 상태 배열도 업데이트
|
// Combobox 열림 상태 배열도 업데이트
|
||||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||||
|
|
@ -621,24 +586,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
targetField: value,
|
targetField: value,
|
||||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||||
};
|
};
|
||||||
} else if (field === "valueType") {
|
|
||||||
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
|
|
||||||
newMappings[index] = {
|
|
||||||
...newMappings[index],
|
|
||||||
valueType: value,
|
|
||||||
// 유형 변경 시 다른 유형의 값 초기화
|
|
||||||
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
|
|
||||||
...(value !== "static" && { staticValue: undefined }),
|
|
||||||
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
|
|
||||||
};
|
|
||||||
} else if (field === "numberingRuleId") {
|
|
||||||
// 🔥 채번 규칙 선택 시 이름도 함께 저장
|
|
||||||
const selectedRule = numberingRules.find((r) => r.ruleId === value);
|
|
||||||
newMappings[index] = {
|
|
||||||
...newMappings[index],
|
|
||||||
numberingRuleId: value,
|
|
||||||
numberingRuleName: selectedRule?.ruleName,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
newMappings[index] = {
|
newMappings[index] = {
|
||||||
...newMappings[index],
|
...newMappings[index],
|
||||||
|
|
@ -1218,203 +1165,54 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 🔥 값 생성 유형 선택 */}
|
{/* 소스 필드 입력/선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">값 생성 방식</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<div className="mt-1 grid grid-cols-3 gap-1">
|
소스 필드
|
||||||
<button
|
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||||
type="button"
|
</Label>
|
||||||
onClick={() => handleMappingChange(index, "valueType", "source")}
|
{hasRestAPISource ? (
|
||||||
className={cn(
|
// REST API 소스인 경우: 직접 입력
|
||||||
"rounded border px-2 py-1 text-xs transition-all",
|
|
||||||
(mapping.valueType === "source" || !mapping.valueType)
|
|
||||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
|
||||||
: "border-gray-200 hover:border-gray-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
소스 필드
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleMappingChange(index, "valueType", "static")}
|
|
||||||
className={cn(
|
|
||||||
"rounded border px-2 py-1 text-xs transition-all",
|
|
||||||
mapping.valueType === "static"
|
|
||||||
? "border-orange-500 bg-orange-50 text-orange-700"
|
|
||||||
: "border-gray-200 hover:border-gray-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
고정값
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
|
|
||||||
className={cn(
|
|
||||||
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
|
|
||||||
mapping.valueType === "autoGenerate"
|
|
||||||
? "border-purple-500 bg-purple-50 text-purple-700"
|
|
||||||
: "border-gray-200 hover:border-gray-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
자동생성
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
|
|
||||||
{(mapping.valueType === "source" || !mapping.valueType) && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">
|
|
||||||
소스 필드
|
|
||||||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
|
||||||
</Label>
|
|
||||||
{hasRestAPISource ? (
|
|
||||||
// REST API 소스인 경우: 직접 입력
|
|
||||||
<Input
|
|
||||||
value={mapping.sourceField || ""}
|
|
||||||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
|
||||||
placeholder="필드명 입력 (예: userId, userName)"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
// 일반 소스인 경우: Combobox 선택
|
|
||||||
<Popover
|
|
||||||
open={mappingSourceFieldsOpenState[index]}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
|
||||||
newState[index] = open;
|
|
||||||
setMappingSourceFieldsOpenState(newState);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
|
||||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
{mapping.sourceField
|
|
||||||
? (() => {
|
|
||||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{field?.label || mapping.sourceField}
|
|
||||||
</span>
|
|
||||||
{field?.label && field.label !== field.name && (
|
|
||||||
<span className="text-muted-foreground font-mono text-xs">
|
|
||||||
{field.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
: "소스 필드 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="p-0"
|
|
||||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="text-xs sm:text-sm">
|
|
||||||
필드를 찾을 수 없습니다.
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{sourceFields.map((field) => (
|
|
||||||
<CommandItem
|
|
||||||
key={field.name}
|
|
||||||
value={field.name}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
handleMappingChange(index, "sourceField", currentValue || null);
|
|
||||||
const newState = [...mappingSourceFieldsOpenState];
|
|
||||||
newState[index] = false;
|
|
||||||
setMappingSourceFieldsOpenState(newState);
|
|
||||||
}}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{field.label || field.name}</span>
|
|
||||||
{field.label && field.label !== field.name && (
|
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
|
||||||
{field.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
{hasRestAPISource && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
|
|
||||||
{mapping.valueType === "static" && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">고정값</Label>
|
|
||||||
<Input
|
<Input
|
||||||
value={mapping.staticValue || ""}
|
value={mapping.sourceField || ""}
|
||||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||||
placeholder="고정값 입력"
|
placeholder="필드명 입력 (예: userId, userName)"
|
||||||
className="mt-1 h-8 text-xs"
|
className="mt-1 h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
// 일반 소스인 경우: Combobox 선택
|
||||||
|
|
||||||
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
|
|
||||||
{mapping.valueType === "autoGenerate" && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">
|
|
||||||
채번 규칙
|
|
||||||
{numberingRulesLoading && <span className="ml-1 text-gray-400">(로딩 중...)</span>}
|
|
||||||
</Label>
|
|
||||||
<Popover
|
<Popover
|
||||||
open={mappingNumberingRulesOpenState[index]}
|
open={mappingSourceFieldsOpenState[index]}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
const newState = [...mappingNumberingRulesOpenState];
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
newState[index] = open;
|
newState[index] = open;
|
||||||
setMappingNumberingRulesOpenState(newState);
|
setMappingSourceFieldsOpenState(newState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={mappingNumberingRulesOpenState[index]}
|
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
disabled={numberingRulesLoading || numberingRules.length === 0}
|
|
||||||
>
|
>
|
||||||
{mapping.numberingRuleId
|
{mapping.sourceField
|
||||||
? (() => {
|
? (() => {
|
||||||
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 overflow-hidden">
|
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||||
<Sparkles className="h-3 w-3 text-purple-500" />
|
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">
|
||||||
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
{field?.label || mapping.sourceField}
|
||||||
</span>
|
</span>
|
||||||
|
{field?.label && field.label !== field.name && (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{field.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
: "채번 규칙 선택"}
|
: "소스 필드 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -1424,36 +1222,37 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
align="start"
|
align="start"
|
||||||
>
|
>
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">
|
<CommandEmpty className="text-xs sm:text-sm">
|
||||||
채번 규칙을 찾을 수 없습니다.
|
필드를 찾을 수 없습니다.
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{numberingRules.map((rule) => (
|
{sourceFields.map((field) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={rule.ruleId}
|
key={field.name}
|
||||||
value={rule.ruleId}
|
value={field.name}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
handleMappingChange(index, "numberingRuleId", currentValue);
|
handleMappingChange(index, "sourceField", currentValue || null);
|
||||||
const newState = [...mappingNumberingRulesOpenState];
|
const newState = [...mappingSourceFieldsOpenState];
|
||||||
newState[index] = false;
|
newState[index] = false;
|
||||||
setMappingNumberingRulesOpenState(newState);
|
setMappingSourceFieldsOpenState(newState);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 h-4 w-4",
|
"mr-2 h-4 w-4",
|
||||||
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{rule.ruleName}</span>
|
<span className="font-medium">{field.label || field.name}</span>
|
||||||
<span className="text-muted-foreground font-mono text-[10px]">
|
{field.label && field.label !== field.name && (
|
||||||
{rule.ruleId}
|
<span className="text-muted-foreground font-mono text-[10px]">
|
||||||
{rule.tableName && ` - ${rule.tableName}`}
|
{field.name}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1462,13 +1261,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{numberingRules.length === 0 && !numberingRulesLoading && (
|
)}
|
||||||
<p className="mt-1 text-xs text-orange-600">
|
{hasRestAPISource && (
|
||||||
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center py-1">
|
<div className="flex items-center justify-center py-1">
|
||||||
<ArrowRight className="h-4 w-4 text-green-600" />
|
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||||
|
|
@ -1603,6 +1400,18 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 정적 값 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||||
|
<Input
|
||||||
|
value={mapping.staticValue || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||||
|
placeholder="소스 필드 대신 고정 값 사용"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1619,8 +1428,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
|
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||||
<p>테이블과 필드는 실제 데이터베이스에서 조회됩니다.</p>
|
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||||
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
<br />
|
||||||
|
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -302,9 +302,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||||
|
|
||||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
|
|
||||||
const isPreviewMode = searchParams.get("preview") === "true";
|
|
||||||
|
|
||||||
// 현재 모드에 따라 표시할 메뉴 결정
|
// 현재 모드에 따라 표시할 메뉴 결정
|
||||||
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
||||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
||||||
|
|
@ -461,15 +458,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
|
|
||||||
if (isPreviewMode) {
|
|
||||||
return (
|
|
||||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI 변환된 메뉴 데이터
|
// UI 변환된 메뉴 데이터
|
||||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -16,14 +15,6 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
|
||||||
import { ProfileFormData } from "@/types/profile";
|
import { ProfileFormData } from "@/types/profile";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { VehicleRegisterData } from "@/lib/api/driver";
|
import { VehicleRegisterData } from "@/lib/api/driver";
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
|
|
||||||
// 언어 정보 타입
|
|
||||||
interface LanguageInfo {
|
|
||||||
langCode: string;
|
|
||||||
langName: string;
|
|
||||||
langNative: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 운전자 정보 타입
|
// 운전자 정보 타입
|
||||||
export interface DriverInfo {
|
export interface DriverInfo {
|
||||||
|
|
@ -157,46 +148,6 @@ export function ProfileModal({
|
||||||
onSave,
|
onSave,
|
||||||
onAlertClose,
|
onAlertClose,
|
||||||
}: ProfileModalProps) {
|
}: ProfileModalProps) {
|
||||||
// 언어 목록 상태
|
|
||||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
|
||||||
|
|
||||||
// 언어 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadLanguages = async () => {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get("/multilang/languages");
|
|
||||||
if (response.data?.success && response.data?.data) {
|
|
||||||
// is_active가 'Y'인 언어만 필터링하고 정렬
|
|
||||||
const activeLanguages = response.data.data
|
|
||||||
.filter((lang: any) => lang.isActive === "Y" || lang.is_active === "Y")
|
|
||||||
.map((lang: any) => ({
|
|
||||||
langCode: lang.langCode || lang.lang_code,
|
|
||||||
langName: lang.langName || lang.lang_name,
|
|
||||||
langNative: lang.langNative || lang.lang_native,
|
|
||||||
}))
|
|
||||||
.sort((a: LanguageInfo, b: LanguageInfo) => {
|
|
||||||
// KR을 먼저 표시
|
|
||||||
if (a.langCode === "KR") return -1;
|
|
||||||
if (b.langCode === "KR") return 1;
|
|
||||||
return a.langCode.localeCompare(b.langCode);
|
|
||||||
});
|
|
||||||
setLanguages(activeLanguages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("언어 목록 로드 실패:", error);
|
|
||||||
// 기본값 설정
|
|
||||||
setLanguages([
|
|
||||||
{ langCode: "KR", langName: "Korean", langNative: "한국어" },
|
|
||||||
{ langCode: "US", langName: "English", langNative: "English" },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
|
||||||
loadLanguages();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// 차량 상태 한글 변환
|
// 차량 상태 한글 변환
|
||||||
const getStatusLabel = (status: string | null) => {
|
const getStatusLabel = (status: string | null) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|
@ -342,15 +293,10 @@ export function ProfileModal({
|
||||||
<SelectValue placeholder="선택해주세요" />
|
<SelectValue placeholder="선택해주세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{languages.length > 0 ? (
|
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
||||||
languages.map((lang) => (
|
<SelectItem value="US">English (US)</SelectItem>
|
||||||
<SelectItem key={lang.langCode} value={lang.langCode}>
|
<SelectItem value="JP">日本語 (JP)</SelectItem>
|
||||||
{lang.langNative} ({lang.langCode})
|
<SelectItem value="CN">中文 (CN)</SelectItem>
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,7 +17,6 @@ import {
|
||||||
Layout,
|
Layout,
|
||||||
Monitor,
|
Monitor,
|
||||||
Square,
|
Square,
|
||||||
Languages,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -35,8 +34,6 @@ interface DesignerToolbarProps {
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
showZoneBorders?: boolean;
|
showZoneBorders?: boolean;
|
||||||
onToggleZoneBorders?: () => void;
|
onToggleZoneBorders?: () => void;
|
||||||
onGenerateMultilang?: () => void;
|
|
||||||
isGeneratingMultilang?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
|
|
@ -53,8 +50,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
showZoneBorders = true,
|
showZoneBorders = true,
|
||||||
onToggleZoneBorders,
|
onToggleZoneBorders,
|
||||||
onGenerateMultilang,
|
|
||||||
isGeneratingMultilang = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
|
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
|
||||||
|
|
@ -231,20 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
|
|
||||||
<div className="h-6 w-px bg-gray-300" />
|
<div className="h-6 w-px bg-gray-300" />
|
||||||
|
|
||||||
{onGenerateMultilang && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onGenerateMultilang}
|
|
||||||
disabled={isGeneratingMultilang}
|
|
||||||
className="flex items-center space-x-1"
|
|
||||||
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
|
||||||
>
|
|
||||||
<Languages className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||||
|
|
|
||||||
|
|
@ -309,10 +309,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
// 🆕 그룹 데이터 조회 함수
|
// 🆕 그룹 데이터 조회 함수
|
||||||
const loadGroupData = async () => {
|
const loadGroupData = async () => {
|
||||||
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
||||||
|
// console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// console.log("🔍 그룹 데이터 조회 시작:", {
|
||||||
|
// tableName: modalState.tableName,
|
||||||
|
// groupByColumns: modalState.groupByColumns,
|
||||||
|
// editData: modalState.editData,
|
||||||
|
// });
|
||||||
|
|
||||||
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
||||||
const groupValues: Record<string, any> = {};
|
const groupValues: Record<string, any> = {};
|
||||||
modalState.groupByColumns.forEach((column) => {
|
modalState.groupByColumns.forEach((column) => {
|
||||||
|
|
@ -322,9 +329,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(groupValues).length === 0) {
|
if (Object.keys(groupValues).length === 0) {
|
||||||
|
// console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log("🔍 그룹 조회 요청:", {
|
||||||
|
// tableName: modalState.tableName,
|
||||||
|
// groupValues,
|
||||||
|
// });
|
||||||
|
|
||||||
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
|
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
|
||||||
|
|
@ -334,19 +347,23 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log("🔍 그룹 조회 응답:", response);
|
||||||
|
|
||||||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||||||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||||
|
|
||||||
if (dataArray.length > 0) {
|
if (dataArray.length > 0) {
|
||||||
|
// console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||||
setGroupData(dataArray);
|
setGroupData(dataArray);
|
||||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||||
} else {
|
} else {
|
||||||
|
console.warn("그룹 데이터가 없습니다:", response);
|
||||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("그룹 데이터 조회 오류:", error);
|
console.error("❌ 그룹 데이터 조회 오류:", error);
|
||||||
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
|
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
|
||||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||||
|
|
@ -654,11 +671,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
|
||||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||||
deletedItem.id,
|
deletedItem.id,
|
||||||
screenData.screenInfo.tableName,
|
screenData.screenInfo.tableName,
|
||||||
modalState.screenId || screenData.screenInfo?.id,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
@ -1026,18 +1041,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||||
|
|
||||||
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
||||||
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
|
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
|
||||||
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
|
|
||||||
const hasUniversalFormModal = screenData.components.some(
|
const hasUniversalFormModal = screenData.components.some(
|
||||||
(c) => {
|
(c) => {
|
||||||
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
|
// 최상위에 universal-form-modal이 있는 경우
|
||||||
if (c.componentType === "universal-form-modal") return true;
|
if (c.componentType === "universal-form-modal") return true;
|
||||||
|
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
|
||||||
|
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
|
||||||
|
if (c.componentType === "conditional-container") return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
|
|
||||||
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
|
|
||||||
|
|
||||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||||
const enrichedFormData = {
|
const enrichedFormData = {
|
||||||
|
|
@ -1079,9 +1093,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
id: modalState.screenId!,
|
id: modalState.screenId!,
|
||||||
tableName: screenData.screenInfo?.tableName,
|
tableName: screenData.screenInfo?.tableName,
|
||||||
}}
|
}}
|
||||||
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
|
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
|
||||||
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
|
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
|
||||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
onSave={hasUniversalFormModal ? undefined : handleSave}
|
||||||
isInModal={true}
|
isInModal={true}
|
||||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||||
groupedData={groupedDataProp}
|
groupedData={groupedDataProp}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -43,7 +42,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||||
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
|
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -101,7 +100,11 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
|
||||||
const isDisabled = !parentValue || loading;
|
const isDisabled = !parentValue || loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={(newValue) => onChange?.(newValue)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
<SelectTrigger className={className}>
|
<SelectTrigger className={className}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -184,17 +187,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
||||||
|
|
||||||
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const menuObjid = useMemo(() => {
|
|
||||||
// 1. ScreenContext에서 가져오기
|
|
||||||
if (screenContext?.menuObjid) return screenContext.menuObjid;
|
|
||||||
// 2. URL 쿼리에서 가져오기
|
|
||||||
const urlMenuObjid = searchParams.get("menuObjid");
|
|
||||||
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
|
|
||||||
}, [screenContext?.menuObjid, searchParams]);
|
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
|
|
@ -206,7 +199,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const hasInitializedWidthsRef = useRef(false);
|
const hasInitializedWidthsRef = useRef(false);
|
||||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
const isResizingRef = useRef(false);
|
const isResizingRef = useRef(false);
|
||||||
|
|
||||||
// TableOptions 상태
|
// TableOptions 상태
|
||||||
const [filters, setFilters] = useState<TableFilter[]>([]);
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
const [grouping, setGrouping] = useState<string[]>([]);
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
|
@ -243,19 +236,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||||
|
|
||||||
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<
|
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
|
|
||||||
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// 테이블 등록 (Context에 등록)
|
// 테이블 등록 (Context에 등록)
|
||||||
const tableId = `datatable-${component.id}`;
|
const tableId = `datatable-${component.id}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!component.tableName || !component.columns) return;
|
if (!component.tableName || !component.columns) return;
|
||||||
|
|
||||||
registerTable({
|
registerTable({
|
||||||
tableId,
|
tableId,
|
||||||
label: component.title || "데이터 테이블",
|
label: component.title || "데이터 테이블",
|
||||||
|
|
@ -332,7 +320,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||||||
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||||||
|
|
||||||
// 이 테이블이 대상 테이블인지 확인
|
// 이 테이블이 대상 테이블인지 확인
|
||||||
if (targetTable === component.tableName) {
|
if (targetTable === component.tableName) {
|
||||||
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
|
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
|
||||||
|
|
@ -377,10 +365,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
for (const col of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
|
|
||||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
|
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
`/table-categories/${component.tableName}/${col.columnName}/values`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
|
|
@ -393,7 +379,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
mappings[col.columnName] = mapping;
|
mappings[col.columnName] = mapping;
|
||||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
|
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
||||||
|
|
@ -408,7 +394,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCategoryMappings();
|
loadCategoryMappings();
|
||||||
}, [component.tableName, component.columns, getColumnWebType, menuObjid]);
|
}, [component.tableName, component.columns, getColumnWebType]);
|
||||||
|
|
||||||
// 파일 상태 확인 함수
|
// 파일 상태 확인 함수
|
||||||
const checkFileStatus = useCallback(
|
const checkFileStatus = useCallback(
|
||||||
|
|
@ -597,13 +583,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
// 없으면 테이블 타입 관리에서 설정된 값 찾기
|
||||||
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
|
||||||
|
|
||||||
// input_type 우선 사용 (category 등)
|
// input_type 우선 사용 (category 등)
|
||||||
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
|
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
|
||||||
if (inputType) {
|
if (inputType) {
|
||||||
return inputType;
|
return inputType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 없으면 webType 사용
|
// 없으면 webType 사용
|
||||||
return tableColumn?.webType || "text";
|
return tableColumn?.webType || "text";
|
||||||
},
|
},
|
||||||
|
|
@ -710,19 +696,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
let linkedFilterValues: Record<string, any> = {};
|
let linkedFilterValues: Record<string, any> = {};
|
||||||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||||||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||||||
|
|
||||||
if (splitPanelContext) {
|
if (splitPanelContext) {
|
||||||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||||
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||||||
(filter) =>
|
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
|
||||||
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
|
filter.targetColumn === component.tableName
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 데이터 선택 여부 확인
|
// 좌측 데이터 선택 여부 확인
|
||||||
hasSelectedLeftData =
|
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
|
||||||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||||
|
|
||||||
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
||||||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||||
const tableSpecificFilters: Record<string, any> = {};
|
const tableSpecificFilters: Record<string, any> = {};
|
||||||
|
|
@ -741,7 +727,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
linkedFilterValues = tableSpecificFilters;
|
linkedFilterValues = tableSpecificFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
||||||
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||||||
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
||||||
|
|
@ -753,9 +739,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 RelatedDataButtons 필터 적용
|
// 🆕 RelatedDataButtons 필터 적용
|
||||||
const relatedButtonFilterValues: Record<string, any> = {};
|
let relatedButtonFilterValues: Record<string, any> = {};
|
||||||
if (relatedButtonFilter) {
|
if (relatedButtonFilter) {
|
||||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||||
}
|
}
|
||||||
|
|
@ -766,16 +752,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
...linkedFilterValues,
|
...linkedFilterValues,
|
||||||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 데이터 조회 시작:", {
|
console.log("🔍 데이터 조회 시작:", {
|
||||||
tableName: component.tableName,
|
tableName: component.tableName,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
linkedFilterValues,
|
linkedFilterValues,
|
||||||
relatedButtonFilterValues,
|
relatedButtonFilterValues,
|
||||||
mergedSearchParams,
|
mergedSearchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||||
page,
|
page,
|
||||||
size: pageSize,
|
size: pageSize,
|
||||||
|
|
@ -783,11 +769,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 데이터 조회 완료:", {
|
console.log("✅ 데이터 조회 완료:", {
|
||||||
tableName: component.tableName,
|
tableName: component.tableName,
|
||||||
dataLength: result.data.length,
|
dataLength: result.data.length,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
page: result.page,
|
page: result.page
|
||||||
});
|
});
|
||||||
|
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
|
|
@ -795,45 +781,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setTotalPages(result.totalPages);
|
setTotalPages(result.totalPages);
|
||||||
setCurrentPage(result.page);
|
setCurrentPage(result.page);
|
||||||
|
|
||||||
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
|
|
||||||
const detectAndLoadCategoryLabels = async () => {
|
|
||||||
const categoryCodes = new Set<string>();
|
|
||||||
result.data.forEach((row: Record<string, any>) => {
|
|
||||||
Object.values(row).forEach((value) => {
|
|
||||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
|
||||||
categoryCodes.add(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
|
|
||||||
|
|
||||||
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
|
|
||||||
const newCodes = Array.from(categoryCodes);
|
|
||||||
|
|
||||||
if (newCodes.length > 0) {
|
|
||||||
try {
|
|
||||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
|
|
||||||
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
|
|
||||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
|
|
||||||
if (response.data.success && response.data.data) {
|
|
||||||
setCategoryCodeLabels((prev) => {
|
|
||||||
const newLabels = {
|
|
||||||
...prev,
|
|
||||||
...response.data.data,
|
|
||||||
};
|
|
||||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
|
|
||||||
return newLabels;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("카테고리 라벨 조회 실패:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
detectAndLoadCategoryLabels();
|
|
||||||
|
|
||||||
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||||
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||||
const primaryKeyField = Object.keys(rowData)[0];
|
const primaryKeyField = Object.keys(rowData)[0];
|
||||||
|
|
@ -969,18 +916,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
try {
|
try {
|
||||||
const columns = await tableTypeApi.getColumns(component.tableName);
|
const columns = await tableTypeApi.getColumns(component.tableName);
|
||||||
setTableColumns(columns);
|
setTableColumns(columns);
|
||||||
|
|
||||||
// 🆕 전체 컬럼 목록 설정
|
// 🆕 전체 컬럼 목록 설정
|
||||||
const columnNames = columns.map((col) => col.columnName);
|
const columnNames = columns.map(col => col.columnName);
|
||||||
setAllAvailableColumns(columnNames);
|
setAllAvailableColumns(columnNames);
|
||||||
|
|
||||||
// 🆕 컬럼명 -> 라벨 매핑 생성
|
// 🆕 컬럼명 -> 라벨 매핑 생성
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
columns.forEach((col) => {
|
columns.forEach(col => {
|
||||||
labels[col.columnName] = col.displayName || col.columnName;
|
labels[col.columnName] = col.displayName || col.columnName;
|
||||||
});
|
});
|
||||||
setColumnLabels(labels);
|
setColumnLabels(labels);
|
||||||
|
|
||||||
// 🆕 localStorage에서 필터 설정 복원
|
// 🆕 localStorage에서 필터 설정 복원
|
||||||
if (user?.userId && component.componentId) {
|
if (user?.userId && component.componentId) {
|
||||||
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
|
||||||
|
|
@ -1036,31 +983,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
// 행 선택 핸들러
|
// 행 선택 핸들러
|
||||||
const handleRowSelect = useCallback(
|
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
|
||||||
(rowIndex: number, isSelected: boolean) => {
|
setSelectedRows((prev) => {
|
||||||
setSelectedRows((prev) => {
|
const newSet = new Set(prev);
|
||||||
const newSet = new Set(prev);
|
if (isSelected) {
|
||||||
if (isSelected) {
|
newSet.add(rowIndex);
|
||||||
newSet.add(rowIndex);
|
} else {
|
||||||
} else {
|
newSet.delete(rowIndex);
|
||||||
newSet.delete(rowIndex);
|
|
||||||
}
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
|
||||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
|
||||||
if (isSelected && data[rowIndex]) {
|
|
||||||
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
|
||||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
|
||||||
} else if (!isSelected) {
|
|
||||||
splitPanelContext.setSelectedLeftData(null);
|
|
||||||
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
return newSet;
|
||||||
[data, splitPanelContext, splitPanelPosition],
|
});
|
||||||
);
|
|
||||||
|
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
||||||
|
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||||
|
if (isSelected && data[rowIndex]) {
|
||||||
|
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
||||||
|
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
||||||
|
} else if (!isSelected) {
|
||||||
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
|
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, splitPanelContext, splitPanelPosition]);
|
||||||
|
|
||||||
// 전체 선택/해제 핸들러
|
// 전체 선택/해제 핸들러
|
||||||
const handleSelectAll = useCallback(
|
const handleSelectAll = useCallback(
|
||||||
|
|
@ -1642,7 +1586,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const options = detailSettings?.options || [];
|
const options = detailSettings?.options || [];
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
|
|
@ -1769,9 +1713,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "category": {
|
case "category": {
|
||||||
// 카테고리 셀렉트 (동적 import)
|
// 카테고리 셀렉트 (동적 import)
|
||||||
const {
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
CategorySelectComponent,
|
|
||||||
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CategorySelectComponent
|
<CategorySelectComponent
|
||||||
|
|
@ -1899,7 +1841,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상세 설정에서 옵션 목록 가져오기
|
// 상세 설정에서 옵션 목록 가져오기
|
||||||
const optionsAdd = detailSettings?.options || [];
|
const optionsAdd = detailSettings?.options || [];
|
||||||
if (optionsAdd.length > 0) {
|
if (optionsAdd.length > 0) {
|
||||||
|
|
@ -2071,9 +2013,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
|
|
||||||
case "category": {
|
case "category": {
|
||||||
// 카테고리 셀렉트 (동적 import)
|
// 카테고리 셀렉트 (동적 import)
|
||||||
const {
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
CategorySelectComponent,
|
|
||||||
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<CategorySelectComponent
|
<CategorySelectComponent
|
||||||
|
|
@ -2211,7 +2151,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const actualWebType = getColumnWebType(column.columnName);
|
const actualWebType = getColumnWebType(column.columnName);
|
||||||
|
|
||||||
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
|
||||||
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
const isFileColumn =
|
||||||
|
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
|
||||||
|
|
||||||
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
|
||||||
if (isFileColumn && rowData) {
|
if (isFileColumn && rowData) {
|
||||||
|
|
@ -2256,25 +2197,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
case "category": {
|
case "category": {
|
||||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
const categoryData = mapping?.[String(value)];
|
||||||
|
|
||||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
|
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
|
||||||
const displayLabel = categoryData?.label || String(value);
|
const displayLabel = categoryData?.label || String(value);
|
||||||
const displayColor = categoryData?.color;
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: displayColor,
|
backgroundColor: displayColor,
|
||||||
borderColor: displayColor,
|
borderColor: displayColor
|
||||||
}}
|
}}
|
||||||
className="text-white"
|
className="text-white"
|
||||||
>
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
|
|
@ -2314,41 +2255,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default: {
|
default:
|
||||||
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
|
return String(value);
|
||||||
const strValue = String(value);
|
|
||||||
if (strValue.startsWith("CATEGORY_")) {
|
|
||||||
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
|
|
||||||
for (const columnName of Object.keys(categoryMappings)) {
|
|
||||||
const mapping = categoryMappings[columnName];
|
|
||||||
const categoryData = mapping?.[strValue];
|
|
||||||
if (categoryData) {
|
|
||||||
// 색상이 있으면 배지로, 없으면 텍스트로 표시
|
|
||||||
if (categoryData.color && categoryData.color !== "none") {
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
style={{
|
|
||||||
backgroundColor: categoryData.color,
|
|
||||||
borderColor: categoryData.color,
|
|
||||||
}}
|
|
||||||
className="text-white"
|
|
||||||
>
|
|
||||||
{categoryData.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span className="text-sm">{categoryData.label}</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
|
|
||||||
const cachedLabel = categoryCodeLabels[strValue];
|
|
||||||
if (cachedLabel) {
|
|
||||||
return <span className="text-sm">{cachedLabel}</span>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strValue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value);
|
return String(value);
|
||||||
|
|
@ -2484,12 +2392,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{visibleColumns.length > 0 ? (
|
{visibleColumns.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<Table style={{ tableLayout: 'fixed' }}>
|
||||||
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
|
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||||
{component.enableDelete && (
|
{component.enableDelete && (
|
||||||
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
|
<TableHead
|
||||||
|
className="px-4"
|
||||||
|
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.size === data.length && data.length > 0}
|
checked={selectedRows.size === data.length && data.length > 0}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
|
|
@ -2498,74 +2409,74 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
)}
|
)}
|
||||||
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
|
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
|
||||||
const columnWidth = columnWidths[column.id];
|
const columnWidth = columnWidths[column.id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={column.id}
|
key={column.id}
|
||||||
ref={(el) => (columnRefs.current[column.id] = el)}
|
ref={(el) => (columnRefs.current[column.id] = el)}
|
||||||
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
|
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
width: columnWidth ? `${columnWidth}px` : undefined,
|
width: columnWidth ? `${columnWidth}px` : undefined,
|
||||||
userSelect: "none",
|
userSelect: 'none'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.label}
|
{column.label}
|
||||||
{/* 리사이즈 핸들 */}
|
{/* 리사이즈 핸들 */}
|
||||||
{columnIndex < visibleColumns.length - 1 && (
|
{columnIndex < visibleColumns.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
|
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
|
||||||
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
|
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const thElement = columnRefs.current[column.id];
|
const thElement = columnRefs.current[column.id];
|
||||||
if (!thElement) return;
|
if (!thElement) return;
|
||||||
|
|
||||||
isResizingRef.current = true;
|
isResizingRef.current = true;
|
||||||
|
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startWidth = columnWidth || thElement.offsetWidth;
|
const startWidth = columnWidth || thElement.offsetWidth;
|
||||||
|
|
||||||
// 드래그 중 텍스트 선택 방지
|
// 드래그 중 텍스트 선택 방지
|
||||||
document.body.style.userSelect = "none";
|
document.body.style.userSelect = 'none';
|
||||||
document.body.style.cursor = "col-resize";
|
document.body.style.cursor = 'col-resize';
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
|
|
||||||
const diff = moveEvent.clientX - startX;
|
const diff = moveEvent.clientX - startX;
|
||||||
const newWidth = Math.max(80, startWidth + diff);
|
const newWidth = Math.max(80, startWidth + diff);
|
||||||
|
|
||||||
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
// 직접 DOM 스타일 변경 (리렌더링 없음)
|
||||||
if (thElement) {
|
if (thElement) {
|
||||||
thElement.style.width = `${newWidth}px`;
|
thElement.style.width = `${newWidth}px`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
// 최종 너비를 state에 저장
|
// 최종 너비를 state에 저장
|
||||||
if (thElement) {
|
if (thElement) {
|
||||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||||
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
|
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 텍스트 선택 복원
|
// 텍스트 선택 복원
|
||||||
document.body.style.userSelect = "";
|
document.body.style.userSelect = '';
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = '';
|
||||||
|
|
||||||
// 약간의 지연 후 리사이즈 플래그 해제
|
// 약간의 지연 후 리사이즈 플래그 해제
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isResizingRef.current = false;
|
isResizingRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2593,7 +2504,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
|
||||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||||
{component.enableDelete && (
|
{component.enableDelete && (
|
||||||
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
|
<TableCell
|
||||||
|
className="px-4"
|
||||||
|
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.has(rowIndex)}
|
checked={selectedRows.has(rowIndex)}
|
||||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||||
|
|
@ -2603,10 +2517,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
{visibleColumns.map((column: DataTableColumn) => {
|
{visibleColumns.map((column: DataTableColumn) => {
|
||||||
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
|
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
|
||||||
return (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
|
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
style={{ textAlign: isNumeric ? "right" : "left" }}
|
style={{ textAlign: isNumeric ? 'right' : 'left' }}
|
||||||
>
|
>
|
||||||
{formatCellValue(row[column.columnName], column, row)}
|
{formatCellValue(row[column.columnName], column, row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
case "entity": {
|
case "entity": {
|
||||||
|
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
return applyStyles(
|
||||||
|
<DynamicWebTypeRenderer
|
||||||
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
|
webType="entity"
|
||||||
componentId: widget.id,
|
config={widget.webTypeConfig}
|
||||||
widgetType: widget.widgetType,
|
props={{
|
||||||
config,
|
component: widget,
|
||||||
appliedSettings: {
|
value: currentValue,
|
||||||
entityName: config?.entityName,
|
onChange: (value: any) => updateFormData(fieldName, value),
|
||||||
displayField: config?.displayField,
|
onFormDataChange: updateFormData,
|
||||||
valueField: config?.valueField,
|
formData: formData,
|
||||||
multiple: config?.multiple,
|
readonly: readonly,
|
||||||
defaultValue: config?.defaultValue,
|
required: required,
|
||||||
},
|
placeholder: widget.placeholder || "엔티티를 선택하세요",
|
||||||
});
|
isInteractive: true,
|
||||||
|
className: "w-full h-full",
|
||||||
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
|
}}
|
||||||
const defaultOptions = [
|
/>,
|
||||||
{ label: "사용자", value: "user" },
|
|
||||||
{ label: "제품", value: "product" },
|
|
||||||
{ label: "주문", value: "order" },
|
|
||||||
{ label: "카테고리", value: "category" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={currentValue || config?.defaultValue || ""}
|
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
|
||||||
disabled={readonly}
|
|
||||||
required={required}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full"
|
|
||||||
style={{ height: "100%" }}
|
|
||||||
style={{
|
|
||||||
...comp.style,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder={finalPlaceholder} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{defaultOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{config?.displayFormat
|
|
||||||
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
|
|
||||||
: option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1909,27 +1876,23 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
|
|
||||||
const hasCustomColors = config?.backgroundColor || config?.textColor;
|
|
||||||
|
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
<button
|
<Button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
|
size="sm"
|
||||||
hasCustomColors
|
variant={config?.variant || "default"}
|
||||||
? ''
|
className="w-full"
|
||||||
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
// 설정값이 있으면 우선 적용
|
||||||
backgroundColor: config?.backgroundColor,
|
backgroundColor: config?.backgroundColor,
|
||||||
color: config?.textColor,
|
color: config?.textColor,
|
||||||
borderColor: config?.borderColor,
|
borderColor: config?.borderColor,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label || "버튼"}
|
{label || "버튼"}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData || undefined}
|
originalData={originalData || undefined}
|
||||||
|
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
|
||||||
onFormDataChange={handleFormDataChange}
|
onFormDataChange={handleFormDataChange}
|
||||||
screenId={screenInfo?.id}
|
screenId={screenInfo?.id}
|
||||||
tableName={screenInfo?.tableName}
|
tableName={screenInfo?.tableName}
|
||||||
|
|
@ -834,18 +835,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
|
|
||||||
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
variant={(config?.variant as any) || "default"}
|
||||||
|
size={(config?.size as any) || "default"}
|
||||||
disabled={config?.disabled}
|
disabled={config?.disabled}
|
||||||
className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
|
|
||||||
hasCustomColors
|
|
||||||
? ''
|
|
||||||
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
// 컴포넌트 스타일 적용
|
// 컴포넌트 스타일 적용
|
||||||
...comp.style,
|
...comp.style,
|
||||||
|
|
@ -858,7 +853,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label || "버튼"}
|
{label || "버튼"}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -637,28 +637,24 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 색상이 설정되어 있으면 variant 스타일을 무시하고 직접 스타일 적용
|
|
||||||
const hasCustomColors = config?.backgroundColor || config?.textColor;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isExecuting || disabled}
|
disabled={isExecuting || disabled}
|
||||||
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
|
variant={config?.variant || "default"}
|
||||||
variant={hasCustomColors ? undefined : (config?.variant || "default")}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-200",
|
"transition-all duration-200",
|
||||||
isExecuting && "cursor-wait opacity-75",
|
isExecuting && "cursor-wait opacity-75",
|
||||||
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
|
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
|
||||||
// 커스텀 색상이 없을 때만 기본 스타일 적용
|
config?.backgroundColor && { backgroundColor: config.backgroundColor },
|
||||||
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
|
config?.textColor && { color: config.textColor },
|
||||||
|
config?.borderColor && { borderColor: config.borderColor },
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// 커스텀 색상이 있을 때만 인라인 스타일 적용
|
backgroundColor: config?.backgroundColor,
|
||||||
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
|
color: config?.textColor,
|
||||||
...(config?.textColor && { color: config.textColor }),
|
borderColor: config?.borderColor,
|
||||||
...(config?.borderColor && { borderColor: config.borderColor }),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 메인 버튼 내용 */}
|
{/* 메인 버튼 내용 */}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
File,
|
File,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
|
||||||
|
|
||||||
// 컴포넌트 렌더러들 자동 등록
|
// 컴포넌트 렌더러들 자동 등록
|
||||||
import "@/lib/registry/components";
|
import "@/lib/registry/components";
|
||||||
|
|
@ -130,9 +129,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
|
||||||
const { getTranslatedText } = useScreenMultiLang();
|
|
||||||
|
|
||||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
const lastUpdatedHeight = React.useRef<number | null>(null);
|
const lastUpdatedHeight = React.useRef<number | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,6 @@ import StyleEditor from "./StyleEditor";
|
||||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||||
import FloatingPanel from "./FloatingPanel";
|
import FloatingPanel from "./FloatingPanel";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import { MultilangSettingsModal } from "./modals/MultilangSettingsModal";
|
|
||||||
import DesignerToolbar from "./DesignerToolbar";
|
import DesignerToolbar from "./DesignerToolbar";
|
||||||
import TablesPanel from "./panels/TablesPanel";
|
import TablesPanel from "./panels/TablesPanel";
|
||||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||||
|
|
@ -145,8 +144,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false);
|
|
||||||
const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false);
|
|
||||||
|
|
||||||
// 🆕 화면에 할당된 메뉴 OBJID
|
// 🆕 화면에 할당된 메뉴 OBJID
|
||||||
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
|
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
|
||||||
|
|
@ -1450,101 +1447,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}, [selectedScreen, layout, screenResolution]);
|
}, [selectedScreen, layout, screenResolution]);
|
||||||
|
|
||||||
// 다국어 자동 생성 핸들러
|
|
||||||
const handleGenerateMultilang = useCallback(async () => {
|
|
||||||
if (!selectedScreen?.screenId) {
|
|
||||||
toast.error("화면 정보가 없습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsGeneratingMultilang(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 공통 유틸 사용하여 라벨 추출
|
|
||||||
const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import(
|
|
||||||
"@/lib/utils/multilangLabelExtractor"
|
|
||||||
);
|
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
|
||||||
|
|
||||||
// 테이블별 컬럼 라벨 로드
|
|
||||||
const tableNames = extractTableNames(layout.components);
|
|
||||||
const columnLabelMap: Record<string, Record<string, string>> = {};
|
|
||||||
|
|
||||||
for (const tableName of tableNames) {
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
||||||
if (response.data?.success && response.data?.data) {
|
|
||||||
const columns = response.data.data.columns || response.data.data;
|
|
||||||
if (Array.isArray(columns)) {
|
|
||||||
columnLabelMap[tableName] = {};
|
|
||||||
columns.forEach((col: any) => {
|
|
||||||
const colName = col.columnName || col.column_name || col.name;
|
|
||||||
const colLabel = col.displayName || col.columnLabel || col.column_label || colName;
|
|
||||||
if (colName) {
|
|
||||||
columnLabelMap[tableName][colName] = colLabel;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 라벨 추출 (다국어 설정과 동일한 로직)
|
|
||||||
const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap);
|
|
||||||
const labels = extractedLabels.map((l) => ({
|
|
||||||
componentId: l.componentId,
|
|
||||||
label: l.label,
|
|
||||||
type: l.type,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (labels.length === 0) {
|
|
||||||
toast.info("다국어로 변환할 라벨이 없습니다.");
|
|
||||||
setIsGeneratingMultilang(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 호출
|
|
||||||
const { generateScreenLabelKeys } = await import("@/lib/api/multilang");
|
|
||||||
const response = await generateScreenLabelKeys({
|
|
||||||
screenId: selectedScreen.screenId,
|
|
||||||
menuObjId: menuObjid?.toString(),
|
|
||||||
labels,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
// 자동 매핑 적용
|
|
||||||
const updatedComponents = applyMultilangMappings(layout.components, response.data);
|
|
||||||
|
|
||||||
// 레이아웃 업데이트
|
|
||||||
const updatedLayout = {
|
|
||||||
...layout,
|
|
||||||
components: updatedComponents,
|
|
||||||
screenResolution: screenResolution,
|
|
||||||
};
|
|
||||||
|
|
||||||
setLayout(updatedLayout);
|
|
||||||
|
|
||||||
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
|
||||||
try {
|
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
|
||||||
toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`);
|
|
||||||
} catch (saveError) {
|
|
||||||
console.error("다국어 매핑 저장 실패:", saveError);
|
|
||||||
toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error(response.error?.details || "다국어 키 생성에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("다국어 생성 실패:", error);
|
|
||||||
toast.error("다국어 키 생성 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsGeneratingMultilang(false);
|
|
||||||
}
|
|
||||||
}, [selectedScreen, layout, screenResolution, menuObjid]);
|
|
||||||
|
|
||||||
// 템플릿 드래그 처리
|
// 템플릿 드래그 처리
|
||||||
const handleTemplateDrop = useCallback(
|
const handleTemplateDrop = useCallback(
|
||||||
(e: React.DragEvent, template: TemplateComponent) => {
|
(e: React.DragEvent, template: TemplateComponent) => {
|
||||||
|
|
@ -4315,9 +4217,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onBack={onBackToList}
|
onBack={onBackToList}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onGenerateMultilang={handleGenerateMultilang}
|
|
||||||
isGeneratingMultilang={isGeneratingMultilang}
|
|
||||||
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
|
||||||
/>
|
/>
|
||||||
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
@ -5100,42 +4999,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
screenId={selectedScreen.screenId}
|
screenId={selectedScreen.screenId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* 다국어 설정 모달 */}
|
|
||||||
<MultilangSettingsModal
|
|
||||||
isOpen={showMultilangSettingsModal}
|
|
||||||
onClose={() => setShowMultilangSettingsModal(false)}
|
|
||||||
components={layout.components}
|
|
||||||
onSave={async (updates) => {
|
|
||||||
if (updates.length === 0) {
|
|
||||||
toast.info("저장할 변경사항이 없습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 공통 유틸 사용하여 매핑 적용
|
|
||||||
const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor");
|
|
||||||
|
|
||||||
// 매핑 형식 변환
|
|
||||||
const mappings = updates.map((u) => ({
|
|
||||||
componentId: u.componentId,
|
|
||||||
keyId: u.langKeyId,
|
|
||||||
langKey: u.langKey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 레이아웃 업데이트
|
|
||||||
const updatedComponents = applyMultilangMappings(layout.components, mappings);
|
|
||||||
setLayout((prev) => ({
|
|
||||||
...prev,
|
|
||||||
components: updatedComponents,
|
|
||||||
}));
|
|
||||||
|
|
||||||
toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("다국어 설정 저장 실패:", error);
|
|
||||||
toast.error("다국어 설정 저장 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -42,8 +42,6 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateC
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||||
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
|
|
||||||
import { Layers } from "lucide-react";
|
|
||||||
import CreateScreenModal from "./CreateScreenModal";
|
import CreateScreenModal from "./CreateScreenModal";
|
||||||
import CopyScreenModal from "./CopyScreenModal";
|
import CopyScreenModal from "./CopyScreenModal";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
@ -95,11 +93,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 그룹 필터 관련 상태
|
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
|
|
||||||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
|
||||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
|
||||||
|
|
||||||
// 검색어 디바운스를 위한 타이머 ref
|
// 검색어 디바운스를 위한 타이머 ref
|
||||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
|
@ -190,25 +183,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 그룹 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
loadGroups();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadGroups = async () => {
|
|
||||||
try {
|
|
||||||
setLoadingGroups(true);
|
|
||||||
const response = await getScreenGroups();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setGroups(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("그룹 목록 조회 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoadingGroups(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이전 타이머 취소
|
// 이전 타이머 취소
|
||||||
|
|
@ -250,11 +224,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
params.companyCode = selectedCompanyCode;
|
params.companyCode = selectedCompanyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그룹 필터
|
|
||||||
if (selectedGroupId !== "all") {
|
|
||||||
params.groupId = selectedGroupId;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||||||
const resp = await screenApi.getScreens(params);
|
const resp = await screenApi.getScreens(params);
|
||||||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||||||
|
|
@ -287,7 +256,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
return () => {
|
return () => {
|
||||||
abort = true;
|
abort = true;
|
||||||
};
|
};
|
||||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
|
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
|
||||||
|
|
||||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||||
|
|
||||||
|
|
@ -702,25 +671,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 그룹 필터 */}
|
|
||||||
<div className="w-full sm:w-[180px]">
|
|
||||||
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
|
|
||||||
<SelectTrigger className="h-10 text-sm">
|
|
||||||
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<SelectValue placeholder="전체 그룹" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">전체 그룹</SelectItem>
|
|
||||||
<SelectItem value="ungrouped">미분류</SelectItem>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<SelectItem key={group.id} value={String(group.id)}>
|
|
||||||
{group.groupName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 입력 */}
|
{/* 검색 입력 */}
|
||||||
<div className="w-full sm:w-[400px]">
|
<div className="w-full sm:w-[400px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -6,8 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
||||||
import { UnifiedColumnInfo } from "@/types/table-management";
|
import { UnifiedColumnInfo } from "@/types/table-management";
|
||||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
|
@ -20,67 +19,6 @@ interface DataFilterConfigPanelProps {
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 접을 수 있는 필터 항목 컴포넌트
|
|
||||||
*/
|
|
||||||
interface FilterItemCollapsibleProps {
|
|
||||||
filter: ColumnFilter;
|
|
||||||
index: number;
|
|
||||||
filterSummary: string;
|
|
||||||
onRemove: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
|
|
||||||
filter,
|
|
||||||
index,
|
|
||||||
filterSummary,
|
|
||||||
onRemove,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<div className="rounded-lg border p-2">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
|
|
||||||
{/* 상단: 필터 번호 + 삭제 버튼 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground text-xs font-medium">필터 {index + 1}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-5 w-5 shrink-0 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* 하단: 필터 요약 (전체 너비 사용) */}
|
|
||||||
<div className="mt-1 pl-4">
|
|
||||||
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
|
|
||||||
{filterSummary}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
|
|
||||||
</div>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 필터 설정 패널
|
* 데이터 필터 설정 패널
|
||||||
* 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용
|
* 테이블 리스트, 분할 패널, 플로우 위젯 등에서 사용
|
||||||
|
|
@ -98,13 +36,13 @@ export function DataFilterConfigPanel({
|
||||||
menuObjid,
|
menuObjid,
|
||||||
sampleColumns: columns.slice(0, 3),
|
sampleColumns: columns.slice(0, 3),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
|
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
|
||||||
config || {
|
config || {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
filters: [],
|
filters: [],
|
||||||
matchType: "all",
|
matchType: "all",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
|
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
|
||||||
|
|
@ -114,7 +52,7 @@ export function DataFilterConfigPanel({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setLocalConfig(config);
|
setLocalConfig(config);
|
||||||
|
|
||||||
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
|
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
|
||||||
config.filters?.forEach((filter) => {
|
config.filters?.forEach((filter) => {
|
||||||
if (filter.valueType === "category" && filter.columnName) {
|
if (filter.valueType === "category" && filter.columnName) {
|
||||||
|
|
@ -131,7 +69,7 @@ export function DataFilterConfigPanel({
|
||||||
return; // 이미 로드되었거나 로딩 중이면 스킵
|
return; // 이미 로드되었거나 로딩 중이면 스킵
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
|
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🔍 카테고리 값 로드 시작:", {
|
console.log("🔍 카테고리 값 로드 시작:", {
|
||||||
|
|
@ -144,7 +82,7 @@ export function DataFilterConfigPanel({
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
false, // includeInactive
|
false, // includeInactive
|
||||||
menuObjid, // 🆕 메뉴 OBJID 전달
|
menuObjid // 🆕 메뉴 OBJID 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("📦 카테고리 값 로드 응답:", response);
|
console.log("📦 카테고리 값 로드 응답:", response);
|
||||||
|
|
@ -154,16 +92,16 @@ export function DataFilterConfigPanel({
|
||||||
value: item.valueCode,
|
value: item.valueCode,
|
||||||
label: item.valueLabel,
|
label: item.valueLabel,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
|
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
|
||||||
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
|
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
|
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
|
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
|
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -207,7 +145,9 @@ export function DataFilterConfigPanel({
|
||||||
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
|
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...localConfig,
|
...localConfig,
|
||||||
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
|
filters: localConfig.filters.map((filter) =>
|
||||||
|
filter.id === filterId ? { ...filter, [field]: value } : filter
|
||||||
|
),
|
||||||
};
|
};
|
||||||
setLocalConfig(newConfig);
|
setLocalConfig(newConfig);
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
|
|
@ -238,7 +178,7 @@ export function DataFilterConfigPanel({
|
||||||
<>
|
<>
|
||||||
{/* 테이블명 표시 */}
|
{/* 테이블명 표시 */}
|
||||||
{tableName && (
|
{tableName && (
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-xs text-muted-foreground">
|
||||||
테이블: <span className="font-medium">{tableName}</span>
|
테이블: <span className="font-medium">{tableName}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -260,127 +200,235 @@ export function DataFilterConfigPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 필터 목록 */}
|
{/* 필터 목록 */}
|
||||||
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
|
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
||||||
{localConfig.filters.map((filter, index) => {
|
{localConfig.filters.map((filter, index) => (
|
||||||
// 연산자 표시 텍스트
|
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
|
||||||
const operatorLabels: Record<string, string> = {
|
<div className="flex items-center justify-between mb-2">
|
||||||
equals: "=",
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
not_equals: "!=",
|
필터 {index + 1}
|
||||||
greater_than: ">",
|
</span>
|
||||||
less_than: "<",
|
<Button
|
||||||
greater_than_or_equal: ">=",
|
variant="ghost"
|
||||||
less_than_or_equal: "<=",
|
size="sm"
|
||||||
between: "BETWEEN",
|
className="h-6 w-6 p-0"
|
||||||
in: "IN",
|
onClick={() => handleRemoveFilter(filter.id)}
|
||||||
not_in: "NOT IN",
|
>
|
||||||
contains: "LIKE",
|
<Trash2 className="h-3 w-3" />
|
||||||
starts_with: "시작",
|
</Button>
|
||||||
ends_with: "끝",
|
</div>
|
||||||
is_null: "IS NULL",
|
|
||||||
is_not_null: "IS NOT NULL",
|
|
||||||
date_range_contains: "기간 내",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 라벨 찾기
|
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||||
const columnLabel =
|
{filter.operator !== "date_range_contains" && (
|
||||||
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
|
<div>
|
||||||
|
<Label className="text-xs">컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.columnName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const column = columns.find((col) => col.columnName === value);
|
||||||
|
|
||||||
|
console.log("🔍 컬럼 선택:", {
|
||||||
|
columnName: value,
|
||||||
|
input_type: column?.input_type,
|
||||||
|
column,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 타입에 따라 valueType 자동 설정
|
||||||
|
let valueType: "static" | "category" | "code" = "static";
|
||||||
|
if (column?.input_type === "category") {
|
||||||
|
valueType = "category";
|
||||||
|
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||||
|
loadCategoryValues(value); // 카테고리 값 로드
|
||||||
|
} else if (column?.input_type === "code") {
|
||||||
|
valueType = "code";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한 번에 모든 변경사항 적용
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.map((f) =>
|
||||||
|
f.id === filter.id
|
||||||
|
? { ...f, columnName: value, valueType, value: "" }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("✅ 필터 설정 업데이트:", {
|
||||||
|
filterId: filter.id,
|
||||||
|
columnName: value,
|
||||||
|
valueType,
|
||||||
|
newConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
{(col.input_type === "category" || col.input_type === "code") && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({col.input_type})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
// 필터 요약 텍스트 생성
|
{/* 연산자 선택 */}
|
||||||
const filterSummary = filter.columnName
|
<div>
|
||||||
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
|
<Label className="text-xs">연산자</Label>
|
||||||
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
|
<Select
|
||||||
? ` ${filter.value}`
|
value={filter.operator}
|
||||||
: ""
|
onValueChange={(value: any) => {
|
||||||
}`
|
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||||
: "설정 필요";
|
if (value === "date_range_contains") {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.map((f) =>
|
||||||
|
f.id === filter.id
|
||||||
|
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
} else {
|
||||||
|
handleFilterChange(filter.id, "operator", value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||||
|
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||||
|
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||||
|
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||||
|
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||||
|
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||||
|
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||||
|
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||||
|
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||||
|
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||||
|
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
||||||
|
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||||
|
<SelectItem value="is_null">NULL</SelectItem>
|
||||||
|
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||||
|
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||||
<FilterItemCollapsible
|
{filter.operator === "date_range_contains" && (
|
||||||
key={filter.id}
|
<>
|
||||||
filter={filter}
|
<div className="col-span-2">
|
||||||
index={index}
|
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||||
filterSummary={filterSummary}
|
💡 날짜 범위 필터링 규칙:
|
||||||
onRemove={() => handleRemoveFilter(filter.id)}
|
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||||
>
|
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||||
{filter.operator !== "date_range_contains" && (
|
</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">컬럼</Label>
|
<Label className="text-xs">시작일 컬럼</Label>
|
||||||
<Select
|
<Select
|
||||||
value={filter.columnName}
|
value={filter.rangeConfig?.startColumn || ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const column = columns.find((col) => col.columnName === value);
|
const newRangeConfig = {
|
||||||
|
...filter.rangeConfig,
|
||||||
console.log("🔍 컬럼 선택:", {
|
startColumn: value,
|
||||||
columnName: value,
|
endColumn: filter.rangeConfig?.endColumn || "",
|
||||||
input_type: column?.input_type,
|
|
||||||
column,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컬럼 타입에 따라 valueType 자동 설정
|
|
||||||
let valueType: "static" | "category" | "code" = "static";
|
|
||||||
if (column?.input_type === "category") {
|
|
||||||
valueType = "category";
|
|
||||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
|
||||||
loadCategoryValues(value); // 카테고리 값 로드
|
|
||||||
} else if (column?.input_type === "code") {
|
|
||||||
valueType = "code";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 한 번에 모든 변경사항 적용
|
|
||||||
const newConfig = {
|
|
||||||
...localConfig,
|
|
||||||
filters: localConfig.filters.map((f) =>
|
|
||||||
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||||
console.log("✅ 필터 설정 업데이트:", {
|
|
||||||
filterId: filter.id,
|
|
||||||
columnName: value,
|
|
||||||
valueType,
|
|
||||||
newConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onConfigChange(newConfig);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{columns.map((col) => (
|
{columns.filter(col =>
|
||||||
|
col.dataType?.toLowerCase().includes('date') ||
|
||||||
|
col.dataType?.toLowerCase().includes('time')
|
||||||
|
).map((col) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
{col.columnLabel || col.columnName}
|
{col.columnLabel || col.columnName}
|
||||||
{(col.input_type === "category" || col.input_type === "code") && (
|
|
||||||
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<Label className="text-xs">종료일 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.rangeConfig?.endColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newRangeConfig = {
|
||||||
|
...filter.rangeConfig,
|
||||||
|
startColumn: filter.rangeConfig?.startColumn || "",
|
||||||
|
endColumn: value,
|
||||||
|
};
|
||||||
|
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.filter(col =>
|
||||||
|
col.dataType?.toLowerCase().includes('date') ||
|
||||||
|
col.dataType?.toLowerCase().includes('time')
|
||||||
|
).map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||||
|
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">연산자</Label>
|
<Label className="text-xs">값 타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={filter.operator}
|
value={filter.valueType}
|
||||||
onValueChange={(value: any) => {
|
onValueChange={(value: any) => {
|
||||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||||
if (value === "date_range_contains") {
|
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...localConfig,
|
...localConfig,
|
||||||
filters: localConfig.filters.map((f) =>
|
filters: localConfig.filters.map((f) =>
|
||||||
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
|
f.id === filter.id
|
||||||
|
? { ...f, valueType: value, value: "TODAY" }
|
||||||
|
: f
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
setLocalConfig(newConfig);
|
setLocalConfig(newConfig);
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
} else {
|
} else {
|
||||||
handleFilterChange(filter.id, "operator", value);
|
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
filters: localConfig.filters.map((f) =>
|
||||||
|
f.id === filter.id
|
||||||
|
? { ...f, valueType: value, value: "" }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -388,240 +436,106 @@ export function DataFilterConfigPanel({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
<SelectItem value="static">직접 입력</SelectItem>
|
||||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
{filter.operator === "date_range_contains" && (
|
||||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
)}
|
||||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
<>
|
||||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
<SelectItem value="code">코드 선택</SelectItem>
|
||||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
</>
|
||||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
)}
|
||||||
<SelectItem value="starts_with">시작 (LIKE value%)</SelectItem>
|
|
||||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
|
||||||
<SelectItem value="is_null">NULL</SelectItem>
|
|
||||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
|
||||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||||
{filter.operator === "date_range_contains" && (
|
{filter.operator !== "is_null" &&
|
||||||
<>
|
filter.operator !== "is_not_null" &&
|
||||||
<div className="col-span-2">
|
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||||
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
|
<div>
|
||||||
💡 날짜 범위 필터링 규칙:
|
<Label className="text-xs">값</Label>
|
||||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
||||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">시작일 컬럼</Label>
|
|
||||||
<Select
|
|
||||||
value={filter.rangeConfig?.startColumn || ""}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newRangeConfig = {
|
|
||||||
...filter.rangeConfig,
|
|
||||||
startColumn: value,
|
|
||||||
endColumn: filter.rangeConfig?.endColumn || "",
|
|
||||||
};
|
|
||||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{columns
|
|
||||||
.filter(
|
|
||||||
(col) =>
|
|
||||||
col.dataType?.toLowerCase().includes("date") ||
|
|
||||||
col.dataType?.toLowerCase().includes("time"),
|
|
||||||
)
|
|
||||||
.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
{col.columnLabel || col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">종료일 컬럼</Label>
|
|
||||||
<Select
|
|
||||||
value={filter.rangeConfig?.endColumn || ""}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newRangeConfig = {
|
|
||||||
...filter.rangeConfig,
|
|
||||||
startColumn: filter.rangeConfig?.startColumn || "",
|
|
||||||
endColumn: value,
|
|
||||||
};
|
|
||||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{columns
|
|
||||||
.filter(
|
|
||||||
(col) =>
|
|
||||||
col.dataType?.toLowerCase().includes("date") ||
|
|
||||||
col.dataType?.toLowerCase().includes("time"),
|
|
||||||
)
|
|
||||||
.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
{col.columnLabel || col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
|
||||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">값 타입</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={filter.valueType}
|
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
||||||
onValueChange={(value: any) => {
|
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
||||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
|
||||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
|
||||||
const newConfig = {
|
|
||||||
...localConfig,
|
|
||||||
filters: localConfig.filters.map((f) =>
|
|
||||||
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onConfigChange(newConfig);
|
|
||||||
} else {
|
|
||||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
|
||||||
const newConfig = {
|
|
||||||
...localConfig,
|
|
||||||
filters: localConfig.filters.map((f) =>
|
|
||||||
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onConfigChange(newConfig);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue />
|
<SelectValue placeholder={
|
||||||
|
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
|
||||||
|
} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">직접 입력</SelectItem>
|
{categoryValues[filter.columnName].map((option) => (
|
||||||
{filter.operator === "date_range_contains" && (
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
{option.label}
|
||||||
)}
|
</SelectItem>
|
||||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
))}
|
||||||
<>
|
|
||||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
|
||||||
<SelectItem value="code">코드 선택</SelectItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
||||||
)}
|
<Input
|
||||||
|
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
||||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
onChange={(e) => {
|
||||||
{filter.operator !== "is_null" &&
|
const values = e.target.value.split(",").map((v) => v.trim());
|
||||||
filter.operator !== "is_not_null" &&
|
handleFilterChange(filter.id, "value", values);
|
||||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
}}
|
||||||
<div>
|
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||||
<Label className="text-xs">값</Label>
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
/>
|
||||||
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
|
) : filter.operator === "between" ? (
|
||||||
<Select
|
<Input
|
||||||
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
|
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||||
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
|
onChange={(e) => {
|
||||||
>
|
const values = e.target.value.split("~").map((v) => v.trim());
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
|
||||||
<SelectValue
|
}}
|
||||||
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
|
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||||
/>
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
) : (
|
||||||
{categoryValues[filter.columnName].map((option) => (
|
<Input
|
||||||
<SelectItem key={option.value} value={option.value}>
|
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||||
{option.label}
|
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||||
</SelectItem>
|
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||||
))}
|
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||||
</SelectContent>
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
</Select>
|
/>
|
||||||
) : filter.operator === "in" || filter.operator === "not_in" ? (
|
|
||||||
<Input
|
|
||||||
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const values = e.target.value.split(",").map((v) => v.trim());
|
|
||||||
handleFilterChange(filter.id, "value", values);
|
|
||||||
}}
|
|
||||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
) : filter.operator === "between" ? (
|
|
||||||
<Input
|
|
||||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const values = e.target.value.split("~").map((v) => v.trim());
|
|
||||||
handleFilterChange(
|
|
||||||
filter.id,
|
|
||||||
"value",
|
|
||||||
values.length === 2 ? values : [values[0] || "", ""],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
|
||||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
|
||||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
|
|
||||||
}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
||||||
{filter.valueType === "category" && categoryValues[filter.columnName]
|
|
||||||
? "카테고리 값을 선택하세요"
|
|
||||||
: filter.operator === "in" || filter.operator === "not_in"
|
|
||||||
? "여러 값은 쉼표(,)로 구분하세요"
|
|
||||||
: filter.operator === "between"
|
|
||||||
? "시작과 종료 값을 ~로 구분하세요"
|
|
||||||
: filter.operator === "date_range_contains"
|
|
||||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
|
||||||
: "필터링할 값을 입력하세요"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
{filter.valueType === "category" && categoryValues[filter.columnName]
|
||||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
? "카테고리 값을 선택하세요"
|
||||||
<div className="rounded-md bg-blue-50 p-2">
|
: filter.operator === "in" || filter.operator === "not_in"
|
||||||
<p className="text-[10px] text-blue-700">오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.</p>
|
? "여러 값은 쉼표(,)로 구분하세요"
|
||||||
</div>
|
: filter.operator === "between"
|
||||||
)}
|
? "시작과 종료 값을 ~로 구분하세요"
|
||||||
</FilterItemCollapsible>
|
: filter.operator === "date_range_contains"
|
||||||
);
|
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||||
})}
|
: "필터링할 값을 입력하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||||
|
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||||
|
<div className="rounded-md bg-blue-50 p-2">
|
||||||
|
<p className="text-[10px] text-blue-700">
|
||||||
|
ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 추가 버튼 */}
|
{/* 필터 추가 버튼 */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
|
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
onClick={handleAddFilter}
|
onClick={handleAddFilter}
|
||||||
disabled={columns.length === 0}
|
disabled={columns.length === 0}
|
||||||
>
|
>
|
||||||
|
|
@ -630,10 +544,13 @@ export function DataFilterConfigPanel({
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{columns.length === 0 && (
|
{columns.length === 0 && (
|
||||||
<p className="text-muted-foreground text-center text-xs">테이블을 먼저 선택해주세요</p>
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
테이블을 먼저 선택해주세요
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react";
|
import { Database, ArrowLeft, Save, Monitor, Smartphone } from "lucide-react";
|
||||||
import { ScreenResolution } from "@/types/screen";
|
import { ScreenResolution } from "@/types/screen";
|
||||||
|
|
||||||
interface SlimToolbarProps {
|
interface SlimToolbarProps {
|
||||||
|
|
@ -13,9 +13,6 @@ interface SlimToolbarProps {
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
onPreview?: () => void;
|
onPreview?: () => void;
|
||||||
onGenerateMultilang?: () => void;
|
|
||||||
isGeneratingMultilang?: boolean;
|
|
||||||
onOpenMultilangSettings?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
|
|
@ -26,9 +23,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
onSave,
|
onSave,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
onPreview,
|
onPreview,
|
||||||
onGenerateMultilang,
|
|
||||||
isGeneratingMultilang = false,
|
|
||||||
onOpenMultilangSettings,
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
<div className="flex h-14 items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 shadow-sm">
|
||||||
|
|
@ -76,29 +70,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||||
<span>반응형 미리보기</span>
|
<span>반응형 미리보기</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onGenerateMultilang && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onGenerateMultilang}
|
|
||||||
disabled={isGeneratingMultilang}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
|
||||||
>
|
|
||||||
<Languages className="h-4 w-4" />
|
|
||||||
<span>{isGeneratingMultilang ? "생성 중..." : "다국어 생성"}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onOpenMultilangSettings && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onOpenMultilangSettings}
|
|
||||||
className="flex items-center space-x-2"
|
|
||||||
title="다국어 키 연결 및 설정을 관리합니다"
|
|
||||||
>
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
<span>다국어 설정</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||||
|
|
|
||||||
|
|
@ -32,27 +32,14 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 커스텀 색상 확인 (config 또는 style에서)
|
|
||||||
const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
|
|
||||||
const hasCustomColor = config?.textColor || style?.color;
|
|
||||||
const hasCustomColors = hasCustomBg || hasCustomColor;
|
|
||||||
|
|
||||||
// 실제 적용할 배경색과 글자색
|
|
||||||
const bgColor = config?.backgroundColor || style?.backgroundColor;
|
|
||||||
const textColor = config?.textColor || style?.color;
|
|
||||||
|
|
||||||
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
|
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
|
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
|
||||||
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
|
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white ${className || ""} `}
|
||||||
hasCustomColors ? '' : 'bg-blue-600 text-white'
|
|
||||||
} ${className || ""}`}
|
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
backgroundColor: bgColor,
|
|
||||||
color: textColor,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
cursor: "pointer", // 선택 가능하도록 포인터 표시
|
cursor: "pointer", // 선택 가능하도록 포인터 표시
|
||||||
|
|
@ -69,13 +56,9 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={disabled || readonly}
|
disabled={disabled || readonly}
|
||||||
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${
|
className={`flex items-center justify-center rounded-md bg-blue-600 px-4 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `}
|
||||||
hasCustomColors ? '' : 'bg-blue-600 text-white hover:bg-blue-700'
|
|
||||||
} ${className || ""}`}
|
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
backgroundColor: bgColor,
|
|
||||||
color: textColor,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
import { TableCategoryValue } from "@/types/tableCategoryValue";
|
||||||
|
|
||||||
// 기본 색상 팔레트
|
// 기본 색상 팔레트
|
||||||
|
|
@ -52,7 +51,6 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
const [valueLabel, setValueLabel] = useState("");
|
const [valueLabel, setValueLabel] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [color, setColor] = useState("none");
|
const [color, setColor] = useState("none");
|
||||||
const [continuousAdd, setContinuousAdd] = useState(false); // 연속 입력 체크박스
|
|
||||||
|
|
||||||
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
|
// 라벨에서 코드 자동 생성 (항상 고유한 코드 생성)
|
||||||
const generateCode = (): string => {
|
const generateCode = (): string => {
|
||||||
|
|
@ -62,12 +60,6 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
return `CATEGORY_${timestamp}${random}`;
|
return `CATEGORY_${timestamp}${random}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setValueLabel("");
|
|
||||||
setDescription("");
|
|
||||||
setColor("none");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!valueLabel.trim()) {
|
if (!valueLabel.trim()) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -85,28 +77,14 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
} as TableCategoryValue);
|
} as TableCategoryValue);
|
||||||
|
|
||||||
// 연속 입력 체크되어 있으면 폼만 초기화하고 모달 유지
|
// 초기화
|
||||||
if (continuousAdd) {
|
setValueLabel("");
|
||||||
resetForm();
|
setDescription("");
|
||||||
} else {
|
setColor("none");
|
||||||
// 연속 입력 아니면 모달 닫기
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
resetForm();
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => {
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
if (!isOpen) {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
onOpenChange(isOpen);
|
|
||||||
}}>
|
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
|
@ -187,42 +165,24 @@ export const CategoryValueAddDialog: React.FC<
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex-col gap-3 sm:flex-row sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
{/* 연속 입력 체크박스 */}
|
<Button
|
||||||
<div className="flex items-center gap-2 w-full sm:w-auto sm:mr-auto">
|
variant="outline"
|
||||||
<Checkbox
|
onClick={() => onOpenChange(false)}
|
||||||
id="continuousAdd"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
checked={continuousAdd}
|
>
|
||||||
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
|
취소
|
||||||
/>
|
</Button>
|
||||||
<label
|
<Button
|
||||||
htmlFor="continuousAdd"
|
onClick={handleSubmit}
|
||||||
className="text-xs sm:text-sm text-muted-foreground cursor-pointer"
|
disabled={!valueLabel.trim()}
|
||||||
>
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
연속 입력
|
>
|
||||||
</label>
|
추가
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!valueLabel.trim()}
|
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
||||||
>
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
await loadCategoryValues();
|
await loadCategoryValues();
|
||||||
// 모달 닫기는 CategoryValueAddDialog에서 연속 입력 체크박스로 제어
|
setIsAddDialogOpen(false);
|
||||||
toast({
|
toast({
|
||||||
title: "성공",
|
title: "성공",
|
||||||
description: "카테고리 값이 추가되었습니다",
|
description: "카테고리 값이 추가되었습니다",
|
||||||
|
|
@ -142,7 +142,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
title: "오류",
|
title: "오류",
|
||||||
description: error.message || "카테고리 값 추가에 실패했습니다",
|
description: error.message || "카테고리 값 추가에 실패했습니다",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ import {
|
||||||
RepeaterItemData,
|
RepeaterItemData,
|
||||||
RepeaterFieldDefinition,
|
RepeaterFieldDefinition,
|
||||||
CalculationFormula,
|
CalculationFormula,
|
||||||
SubDataState,
|
|
||||||
} from "@/types/repeater";
|
} from "@/types/repeater";
|
||||||
import { SubDataLookupPanel } from "@/lib/registry/components/repeater-field-group/SubDataLookupPanel";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||||
|
|
@ -70,12 +68,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
layout = "grid", // 기본값을 grid로 설정
|
layout = "grid", // 기본값을 grid로 설정
|
||||||
showDivider = true,
|
showDivider = true,
|
||||||
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
|
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
|
||||||
subDataLookup,
|
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
// 하위 데이터 조회 상태 관리 (각 항목별)
|
|
||||||
const [subDataStates, setSubDataStates] = useState<Map<number, SubDataState>>(new Map());
|
|
||||||
|
|
||||||
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
|
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
|
||||||
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
|
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
|
||||||
|
|
||||||
|
|
@ -278,111 +272,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 드래그 앤 드롭 (순서 변경)
|
// 드래그 앤 드롭 (순서 변경)
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 하위 데이터 선택 핸들러
|
|
||||||
const handleSubDataSelection = (itemIndex: number, selectedItem: any | null, maxValue: number | null) => {
|
|
||||||
console.log("[RepeaterInput] 하위 데이터 선택:", { itemIndex, selectedItem, maxValue });
|
|
||||||
|
|
||||||
// 상태 업데이트
|
|
||||||
setSubDataStates((prev) => {
|
|
||||||
const newMap = new Map(prev);
|
|
||||||
const currentState = newMap.get(itemIndex) || {
|
|
||||||
itemIndex,
|
|
||||||
data: [],
|
|
||||||
selectedItem: null,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
isExpanded: false,
|
|
||||||
};
|
|
||||||
newMap.set(itemIndex, {
|
|
||||||
...currentState,
|
|
||||||
selectedItem,
|
|
||||||
});
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택된 항목 정보를 item에 저장
|
|
||||||
if (selectedItem && subDataLookup) {
|
|
||||||
const newItems = [...items];
|
|
||||||
newItems[itemIndex] = {
|
|
||||||
...newItems[itemIndex],
|
|
||||||
_subDataSelection: selectedItem,
|
|
||||||
_subDataMaxValue: maxValue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
|
|
||||||
// 예: warehouse_code, location_code 등
|
|
||||||
if (subDataLookup.lookup.displayColumns) {
|
|
||||||
subDataLookup.lookup.displayColumns.forEach((col) => {
|
|
||||||
if (selectedItem[col] !== undefined) {
|
|
||||||
// 필드가 정의되어 있으면 복사
|
|
||||||
const fieldDef = fields.find((f) => f.name === col);
|
|
||||||
if (fieldDef || col.includes("_code") || col.includes("_id")) {
|
|
||||||
newItems[itemIndex][col] = selectedItem[col];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setItems(newItems);
|
|
||||||
|
|
||||||
// onChange 호출
|
|
||||||
const dataWithMeta = config.targetTable
|
|
||||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
|
||||||
: newItems;
|
|
||||||
onChange?.(dataWithMeta);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 조건부 입력 활성화 여부 확인
|
|
||||||
const isConditionalInputEnabled = (itemIndex: number, fieldName: string): boolean => {
|
|
||||||
if (!subDataLookup?.enabled) return true;
|
|
||||||
if (subDataLookup.conditionalInput?.targetField !== fieldName) return true;
|
|
||||||
|
|
||||||
const subState = subDataStates.get(itemIndex);
|
|
||||||
if (!subState?.selectedItem) return false;
|
|
||||||
|
|
||||||
const { requiredFields, requiredMode = "all" } = subDataLookup.selection;
|
|
||||||
if (!requiredFields || requiredFields.length === 0) return true;
|
|
||||||
|
|
||||||
if (requiredMode === "any") {
|
|
||||||
return requiredFields.some((field) => {
|
|
||||||
const value = subState.selectedItem[field];
|
|
||||||
return value !== undefined && value !== null && value !== "";
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return requiredFields.every((field) => {
|
|
||||||
const value = subState.selectedItem[field];
|
|
||||||
return value !== undefined && value !== null && value !== "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 최대값 가져오기
|
|
||||||
const getMaxValueForField = (itemIndex: number, fieldName: string): number | null => {
|
|
||||||
if (!subDataLookup?.enabled) return null;
|
|
||||||
if (subDataLookup.conditionalInput?.targetField !== fieldName) return null;
|
|
||||||
if (!subDataLookup.conditionalInput?.maxValueField) return null;
|
|
||||||
|
|
||||||
const subState = subDataStates.get(itemIndex);
|
|
||||||
if (!subState?.selectedItem) return null;
|
|
||||||
|
|
||||||
const maxVal = subState.selectedItem[subDataLookup.conditionalInput.maxValueField];
|
|
||||||
return typeof maxVal === "number" ? maxVal : parseFloat(maxVal) || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 경고 임계값 체크
|
|
||||||
const checkWarningThreshold = (itemIndex: number, fieldName: string, value: number): boolean => {
|
|
||||||
if (!subDataLookup?.enabled) return false;
|
|
||||||
if (subDataLookup.conditionalInput?.targetField !== fieldName) return false;
|
|
||||||
|
|
||||||
const maxValue = getMaxValueForField(itemIndex, fieldName);
|
|
||||||
if (maxValue === null || maxValue === 0) return false;
|
|
||||||
|
|
||||||
const threshold = subDataLookup.conditionalInput?.warningThreshold ?? 90;
|
|
||||||
const percentage = (value / maxValue) * 100;
|
|
||||||
return percentage >= threshold;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragStart = (index: number) => {
|
const handleDragStart = (index: number) => {
|
||||||
if (!allowReorder || readonly || disabled) return;
|
if (!allowReorder || readonly || disabled) return;
|
||||||
setDraggedIndex(index);
|
setDraggedIndex(index);
|
||||||
|
|
@ -500,26 +389,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||||
const isReadonly = disabled || readonly || field.readonly;
|
const isReadonly = disabled || readonly || field.readonly;
|
||||||
|
|
||||||
// 조건부 입력 비활성화 체크
|
|
||||||
const isConditionalDisabled =
|
|
||||||
subDataLookup?.enabled &&
|
|
||||||
subDataLookup.conditionalInput?.targetField === field.name &&
|
|
||||||
!isConditionalInputEnabled(itemIndex, field.name);
|
|
||||||
|
|
||||||
// 최대값 및 경고 체크
|
|
||||||
const maxValue = getMaxValueForField(itemIndex, field.name);
|
|
||||||
const numValue = parseFloat(value) || 0;
|
|
||||||
const showWarning = checkWarningThreshold(itemIndex, field.name, numValue);
|
|
||||||
const exceedsMax = maxValue !== null && numValue > maxValue;
|
|
||||||
|
|
||||||
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||||
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||||
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
value: value || "",
|
value: value || "",
|
||||||
disabled: isReadonly || isConditionalDisabled,
|
disabled: isReadonly,
|
||||||
placeholder: isConditionalDisabled ? "재고 선택 필요" : defaultPlaceholder,
|
placeholder: defaultPlaceholder,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -692,37 +569,23 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
type="number"
|
type="number"
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||||
min={field.validation?.min}
|
min={field.validation?.min}
|
||||||
max={maxValue !== null ? maxValue : field.validation?.max}
|
max={field.validation?.max}
|
||||||
className={cn("pr-1", exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")}
|
className="pr-1"
|
||||||
/>
|
/>
|
||||||
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||||
{exceedsMax && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-red-500">최대 {maxValue}까지 입력 가능</div>
|
|
||||||
)}
|
|
||||||
{showWarning && !exceedsMax && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-amber-600">재고의 {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% 이상</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-[80px]">
|
<Input
|
||||||
<Input
|
{...commonProps}
|
||||||
{...commonProps}
|
type="number"
|
||||||
type="number"
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
min={field.validation?.min}
|
||||||
min={field.validation?.min}
|
max={field.validation?.max}
|
||||||
max={maxValue !== null ? maxValue : field.validation?.max}
|
className="min-w-[80px]"
|
||||||
className={cn(exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")}
|
/>
|
||||||
/>
|
|
||||||
{exceedsMax && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-red-500">최대 {maxValue}까지 입력 가능</div>
|
|
||||||
)}
|
|
||||||
{showWarning && !exceedsMax && (
|
|
||||||
<div className="mt-0.5 text-[10px] text-amber-600">재고의 {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% 이상</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
case "email":
|
case "email":
|
||||||
|
|
@ -891,9 +754,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 그리드/테이블 형식 렌더링
|
// 그리드/테이블 형식 렌더링
|
||||||
const renderGridLayout = () => {
|
const renderGridLayout = () => {
|
||||||
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
|
|
||||||
const linkColumn = subDataLookup?.lookup?.linkColumn;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card">
|
<div className="bg-card">
|
||||||
<Table>
|
<Table>
|
||||||
|
|
@ -915,83 +775,55 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item, itemIndex) => {
|
{items.map((item, itemIndex) => (
|
||||||
// 하위 데이터 조회용 연결 값
|
<TableRow
|
||||||
const linkValue = linkColumn ? item[linkColumn] : null;
|
key={itemIndex}
|
||||||
|
className={cn(
|
||||||
|
"bg-background hover:bg-muted/50 transition-colors",
|
||||||
|
draggedIndex === itemIndex && "opacity-50",
|
||||||
|
)}
|
||||||
|
draggable={allowReorder && !readonly && !disabled}
|
||||||
|
onDragStart={() => handleDragStart(itemIndex)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
||||||
|
onDrop={(e) => handleDrop(e, itemIndex)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
{/* 인덱스 번호 */}
|
||||||
|
{showIndex && (
|
||||||
|
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* 드래그 핸들 */}
|
||||||
<React.Fragment key={itemIndex}>
|
{allowReorder && !readonly && !disabled && (
|
||||||
<TableRow
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
className={cn(
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
"bg-background hover:bg-muted/50 transition-colors",
|
</TableCell>
|
||||||
draggedIndex === itemIndex && "opacity-50",
|
)}
|
||||||
)}
|
|
||||||
draggable={allowReorder && !readonly && !disabled}
|
|
||||||
onDragStart={() => handleDragStart(itemIndex)}
|
|
||||||
onDragOver={(e) => handleDragOver(e, itemIndex)}
|
|
||||||
onDrop={(e) => handleDrop(e, itemIndex)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
{/* 인덱스 번호 */}
|
|
||||||
{showIndex && (
|
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 드래그 핸들 */}
|
{/* 필드들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{fields.map((field) => (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell key={field.name} className="h-12 px-2.5 py-2">
|
||||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
{renderField(field, itemIndex, item[field.name])}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* 필드들 */}
|
{/* 삭제 버튼 */}
|
||||||
{fields.map((field) => (
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
<TableCell key={field.name} className="h-12 px-2.5 py-2">
|
{!readonly && !disabled && (
|
||||||
{renderField(field, itemIndex, item[field.name])}
|
<Button
|
||||||
</TableCell>
|
type="button"
|
||||||
))}
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
{/* 삭제 버튼 */}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
{!readonly && !disabled && (
|
title="항목 제거"
|
||||||
<Button
|
>
|
||||||
type="button"
|
<X className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
|
||||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
|
||||||
title="항목 제거"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
|
|
||||||
{/* 하위 데이터 조회 패널 (인라인) */}
|
|
||||||
{subDataLookup?.enabled && linkValue && (
|
|
||||||
<TableRow className="bg-gray-50/50">
|
|
||||||
<TableCell
|
|
||||||
colSpan={
|
|
||||||
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
|
|
||||||
}
|
|
||||||
className="px-2.5 py-2"
|
|
||||||
>
|
|
||||||
<SubDataLookupPanel
|
|
||||||
config={subDataLookup}
|
|
||||||
linkValue={linkValue}
|
|
||||||
itemIndex={itemIndex}
|
|
||||||
onSelectionChange={(selectedItem, maxValue) =>
|
|
||||||
handleSubDataSelection(itemIndex, selectedItem, maxValue)
|
|
||||||
}
|
|
||||||
disabled={readonly || disabled}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</TableCell>
|
||||||
);
|
</TableRow>
|
||||||
})}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1000,15 +832,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 카드 형식 렌더링 (기존 방식)
|
// 카드 형식 렌더링 (기존 방식)
|
||||||
const renderCardLayout = () => {
|
const renderCardLayout = () => {
|
||||||
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
|
|
||||||
const linkColumn = subDataLookup?.lookup?.linkColumn;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map((item, itemIndex) => {
|
{items.map((item, itemIndex) => {
|
||||||
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
|
const isCollapsed = collapsible && collapsedItems.has(itemIndex);
|
||||||
// 하위 데이터 조회용 연결 값
|
|
||||||
const linkValue = linkColumn ? item[linkColumn] : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -1080,21 +907,6 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하위 데이터 조회 패널 (인라인) */}
|
|
||||||
{subDataLookup?.enabled && linkValue && (
|
|
||||||
<div className="mt-3 border-t pt-3">
|
|
||||||
<SubDataLookupPanel
|
|
||||||
config={subDataLookup}
|
|
||||||
linkValue={linkValue}
|
|
||||||
itemIndex={itemIndex}
|
|
||||||
onSelectionChange={(selectedItem, maxValue) =>
|
|
||||||
handleSubDataSelection(itemIndex, selectedItem, maxValue)
|
|
||||||
}
|
|
||||||
disabled={readonly || disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator, Database, ArrowUp, ArrowDown } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
RepeaterFieldGroupConfig,
|
RepeaterFieldGroupConfig,
|
||||||
RepeaterFieldDefinition,
|
RepeaterFieldDefinition,
|
||||||
RepeaterFieldType,
|
RepeaterFieldType,
|
||||||
CalculationOperator,
|
CalculationOperator,
|
||||||
CalculationFormula,
|
CalculationFormula,
|
||||||
SubDataLookupConfig,
|
|
||||||
} from "@/types/repeater";
|
} from "@/types/repeater";
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
import { ColumnInfo } from "@/types/screen";
|
import { ColumnInfo } from "@/types/screen";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -96,56 +93,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 순서 변경 (위로)
|
|
||||||
const moveFieldUp = (index: number) => {
|
|
||||||
if (index <= 0) return;
|
|
||||||
const newFields = [...localFields];
|
|
||||||
[newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
|
|
||||||
handleFieldsChange(newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 순서 변경 (아래로)
|
|
||||||
const moveFieldDown = (index: number) => {
|
|
||||||
if (index >= localFields.length - 1) return;
|
|
||||||
const newFields = [...localFields];
|
|
||||||
[newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
|
|
||||||
handleFieldsChange(newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드래그 앤 드롭 상태
|
|
||||||
const [draggedFieldIndex, setDraggedFieldIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// 필드 드래그 시작
|
|
||||||
const handleFieldDragStart = (index: number) => {
|
|
||||||
setDraggedFieldIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 드래그 오버
|
|
||||||
const handleFieldDragOver = (e: React.DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 드롭
|
|
||||||
const handleFieldDrop = (e: React.DragEvent, targetIndex: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (draggedFieldIndex === null || draggedFieldIndex === targetIndex) {
|
|
||||||
setDraggedFieldIndex(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFields = [...localFields];
|
|
||||||
const draggedField = newFields[draggedFieldIndex];
|
|
||||||
newFields.splice(draggedFieldIndex, 1);
|
|
||||||
newFields.splice(targetIndex, 0, draggedField);
|
|
||||||
handleFieldsChange(newFields);
|
|
||||||
setDraggedFieldIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 드래그 종료
|
|
||||||
const handleFieldDragEnd = () => {
|
|
||||||
setDraggedFieldIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||||
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||||
setLocalInputs((prev) => ({
|
setLocalInputs((prev) => ({
|
||||||
|
|
@ -182,46 +129,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||||
const [tableSearchValue, setTableSearchValue] = useState("");
|
const [tableSearchValue, setTableSearchValue] = useState("");
|
||||||
|
|
||||||
// 하위 데이터 조회 설정 상태
|
|
||||||
const [subDataTableSelectOpen, setSubDataTableSelectOpen] = useState(false);
|
|
||||||
const [subDataTableSearchValue, setSubDataTableSearchValue] = useState("");
|
|
||||||
const [subDataTableColumns, setSubDataTableColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [subDataLinkColumnOpen, setSubDataLinkColumnOpen] = useState(false);
|
|
||||||
const [subDataLinkColumnSearch, setSubDataLinkColumnSearch] = useState("");
|
|
||||||
|
|
||||||
// 하위 데이터 조회 테이블 컬럼 로드
|
|
||||||
const loadSubDataTableColumns = async (tableName: string) => {
|
|
||||||
if (!tableName) {
|
|
||||||
setSubDataTableColumns([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
||||||
let columns: ColumnInfo[] = [];
|
|
||||||
if (response.data?.success && response.data?.data) {
|
|
||||||
if (Array.isArray(response.data.data.columns)) {
|
|
||||||
columns = response.data.data.columns;
|
|
||||||
} else if (Array.isArray(response.data.data)) {
|
|
||||||
columns = response.data.data;
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(response.data)) {
|
|
||||||
columns = response.data;
|
|
||||||
}
|
|
||||||
setSubDataTableColumns(columns);
|
|
||||||
console.log("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드:", { tableName, count: columns.length });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드 실패:", error);
|
|
||||||
setSubDataTableColumns([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 하위 데이터 테이블이 설정되어 있으면 컬럼 로드
|
|
||||||
useEffect(() => {
|
|
||||||
if (config.subDataLookup?.lookup?.tableName) {
|
|
||||||
loadSubDataTableColumns(config.subDataLookup.lookup.tableName);
|
|
||||||
}
|
|
||||||
}, [config.subDataLookup?.lookup?.tableName]);
|
|
||||||
|
|
||||||
// 필터링된 테이블 목록
|
// 필터링된 테이블 목록
|
||||||
const filteredTables = useMemo(() => {
|
const filteredTables = useMemo(() => {
|
||||||
if (!tableSearchValue) return allTables;
|
if (!tableSearchValue) return allTables;
|
||||||
|
|
@ -239,86 +146,6 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
return table ? table.displayName || table.tableName : config.targetTable;
|
return table ? table.displayName || table.tableName : config.targetTable;
|
||||||
}, [config.targetTable, allTables]);
|
}, [config.targetTable, allTables]);
|
||||||
|
|
||||||
// 하위 데이터 조회 테이블 표시명
|
|
||||||
const selectedSubDataTableLabel = useMemo(() => {
|
|
||||||
const tableName = config.subDataLookup?.lookup?.tableName;
|
|
||||||
if (!tableName) return "테이블을 선택하세요";
|
|
||||||
const table = allTables.find((t) => t.tableName === tableName);
|
|
||||||
return table ? `${table.displayName || table.tableName} (${tableName})` : tableName;
|
|
||||||
}, [config.subDataLookup?.lookup?.tableName, allTables]);
|
|
||||||
|
|
||||||
// 필터링된 하위 데이터 테이블 컬럼
|
|
||||||
const filteredSubDataColumns = useMemo(() => {
|
|
||||||
if (!subDataLinkColumnSearch) return subDataTableColumns;
|
|
||||||
const searchLower = subDataLinkColumnSearch.toLowerCase();
|
|
||||||
return subDataTableColumns.filter(
|
|
||||||
(col) =>
|
|
||||||
col.columnName.toLowerCase().includes(searchLower) ||
|
|
||||||
(col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)),
|
|
||||||
);
|
|
||||||
}, [subDataTableColumns, subDataLinkColumnSearch]);
|
|
||||||
|
|
||||||
// 하위 데이터 조회 설정 변경 핸들러
|
|
||||||
const handleSubDataLookupChange = (path: string, value: any) => {
|
|
||||||
const currentConfig = config.subDataLookup || {
|
|
||||||
enabled: false,
|
|
||||||
lookup: { tableName: "", linkColumn: "", displayColumns: [] },
|
|
||||||
selection: { mode: "single", requiredFields: [], requiredMode: "all" },
|
|
||||||
conditionalInput: { targetField: "" },
|
|
||||||
ui: { expandMode: "inline", maxHeight: "150px", showSummary: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 경로를 따라 중첩 객체 업데이트
|
|
||||||
const pathParts = path.split(".");
|
|
||||||
let target: any = { ...currentConfig };
|
|
||||||
const newConfig = target;
|
|
||||||
|
|
||||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
||||||
const part = pathParts[i];
|
|
||||||
target[part] = { ...target[part] };
|
|
||||||
target = target[part];
|
|
||||||
}
|
|
||||||
target[pathParts[pathParts.length - 1]] = value;
|
|
||||||
|
|
||||||
onChange({
|
|
||||||
...config,
|
|
||||||
subDataLookup: newConfig as SubDataLookupConfig,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 표시 컬럼 토글 핸들러
|
|
||||||
const handleDisplayColumnToggle = (columnName: string, checked: boolean) => {
|
|
||||||
const currentColumns = config.subDataLookup?.lookup?.displayColumns || [];
|
|
||||||
let newColumns: string[];
|
|
||||||
if (checked) {
|
|
||||||
newColumns = [...currentColumns, columnName];
|
|
||||||
} else {
|
|
||||||
newColumns = currentColumns.filter((c) => c !== columnName);
|
|
||||||
}
|
|
||||||
handleSubDataLookupChange("lookup.displayColumns", newColumns);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필수 선택 필드 토글 핸들러
|
|
||||||
const handleRequiredFieldToggle = (fieldName: string, checked: boolean) => {
|
|
||||||
const currentFields = config.subDataLookup?.selection?.requiredFields || [];
|
|
||||||
let newFields: string[];
|
|
||||||
if (checked) {
|
|
||||||
newFields = [...currentFields, fieldName];
|
|
||||||
} else {
|
|
||||||
newFields = currentFields.filter((f) => f !== fieldName);
|
|
||||||
}
|
|
||||||
handleSubDataLookupChange("selection.requiredFields", newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 라벨 업데이트 핸들러
|
|
||||||
const handleColumnLabelChange = (columnName: string, label: string) => {
|
|
||||||
const currentLabels = config.subDataLookup?.lookup?.columnLabels || {};
|
|
||||||
handleSubDataLookupChange("lookup.columnLabels", {
|
|
||||||
...currentLabels,
|
|
||||||
[columnName]: label,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 대상 테이블 선택 */}
|
{/* 대상 테이블 선택 */}
|
||||||
|
|
@ -423,485 +250,24 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하위 데이터 조회 설정 */}
|
|
||||||
<div className="space-y-3 rounded-lg border-2 border-purple-200 bg-purple-50/30 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Database className="h-4 w-4 text-purple-600" />
|
|
||||||
<Label className="text-sm font-semibold text-purple-800">하위 데이터 조회</Label>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={config.subDataLookup?.enabled ?? false}
|
|
||||||
onCheckedChange={(checked) => handleSubDataLookupChange("enabled", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-purple-600">
|
|
||||||
품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{config.subDataLookup?.enabled && (
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
{/* 조회 테이블 선택 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">조회 테이블</Label>
|
|
||||||
<Popover open={subDataTableSelectOpen} onOpenChange={setSubDataTableSelectOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={subDataTableSelectOpen}
|
|
||||||
className="h-9 w-full justify-between text-xs"
|
|
||||||
>
|
|
||||||
{selectedSubDataTableLabel}
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="테이블 검색..."
|
|
||||||
value={subDataTableSearchValue}
|
|
||||||
onValueChange={setSubDataTableSearchValue}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup className="max-h-48 overflow-auto">
|
|
||||||
{allTables
|
|
||||||
.filter((table) => {
|
|
||||||
if (!subDataTableSearchValue) return true;
|
|
||||||
const searchLower = subDataTableSearchValue.toLowerCase();
|
|
||||||
return (
|
|
||||||
table.tableName.toLowerCase().includes(searchLower) ||
|
|
||||||
(table.displayName && table.displayName.toLowerCase().includes(searchLower))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((table) => (
|
|
||||||
<CommandItem
|
|
||||||
key={table.tableName}
|
|
||||||
value={table.tableName}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
handleSubDataLookupChange("lookup.tableName", currentValue);
|
|
||||||
loadSubDataTableColumns(currentValue);
|
|
||||||
setSubDataTableSelectOpen(false);
|
|
||||||
setSubDataTableSearchValue("");
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
config.subDataLookup?.lookup?.tableName === table.tableName
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{table.displayName || table.tableName}</div>
|
|
||||||
<div className="text-gray-500">{table.tableName}</div>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-[10px] text-purple-500">예: inventory (재고), price_list (단가표)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 연결 컬럼 선택 */}
|
|
||||||
{config.subDataLookup?.lookup?.tableName && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">연결 컬럼</Label>
|
|
||||||
<Popover open={subDataLinkColumnOpen} onOpenChange={setSubDataLinkColumnOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={subDataLinkColumnOpen}
|
|
||||||
className="h-9 w-full justify-between text-xs"
|
|
||||||
>
|
|
||||||
{config.subDataLookup?.lookup?.linkColumn
|
|
||||||
? (() => {
|
|
||||||
const col = subDataTableColumns.find(
|
|
||||||
(c) => c.columnName === config.subDataLookup?.lookup?.linkColumn,
|
|
||||||
);
|
|
||||||
return col
|
|
||||||
? `${col.columnLabel || col.columnName} (${col.columnName})`
|
|
||||||
: config.subDataLookup?.lookup?.linkColumn;
|
|
||||||
})()
|
|
||||||
: "연결 컬럼 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="컬럼 검색..."
|
|
||||||
value={subDataLinkColumnSearch}
|
|
||||||
onValueChange={setSubDataLinkColumnSearch}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup className="max-h-48 overflow-auto">
|
|
||||||
{filteredSubDataColumns.map((col) => (
|
|
||||||
<CommandItem
|
|
||||||
key={col.columnName}
|
|
||||||
value={col.columnName}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
handleSubDataLookupChange("lookup.linkColumn", currentValue);
|
|
||||||
setSubDataLinkColumnOpen(false);
|
|
||||||
setSubDataLinkColumnSearch("");
|
|
||||||
}}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-3 w-3",
|
|
||||||
config.subDataLookup?.lookup?.linkColumn === col.columnName
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{col.columnLabel || col.columnName}</div>
|
|
||||||
<div className="text-gray-500">{col.columnName}</div>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-[10px] text-purple-500">상위 데이터와 연결할 컬럼 (예: item_code)</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 표시 컬럼 선택 */}
|
|
||||||
{config.subDataLookup?.lookup?.tableName && subDataTableColumns.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">표시 컬럼</Label>
|
|
||||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-white p-2">
|
|
||||||
{subDataTableColumns.map((col) => {
|
|
||||||
const isSelected = config.subDataLookup?.lookup?.displayColumns?.includes(col.columnName);
|
|
||||||
return (
|
|
||||||
<div key={col.columnName} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`display-col-${col.columnName}`}
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`display-col-${col.columnName}`}
|
|
||||||
className="flex-1 cursor-pointer text-xs font-normal"
|
|
||||||
>
|
|
||||||
{col.columnLabel || col.columnName}
|
|
||||||
<span className="ml-1 text-gray-400">({col.columnName})</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-purple-500">조회 결과에 표시할 컬럼들 (예: 창고, 위치, 수량)</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 선택 설정 */}
|
|
||||||
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
|
|
||||||
<div className="space-y-3 border-t border-purple-200 pt-3">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">선택 설정</Label>
|
|
||||||
|
|
||||||
{/* 선택 모드 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">선택 모드</Label>
|
|
||||||
<Select
|
|
||||||
value={config.subDataLookup?.selection?.mode || "single"}
|
|
||||||
onValueChange={(v) => handleSubDataLookupChange("selection.mode", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="single" className="text-xs">
|
|
||||||
단일 선택
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="multiple" className="text-xs">
|
|
||||||
다중 선택
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필수 선택 필드 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">필수 선택 필드</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{config.subDataLookup?.lookup?.displayColumns?.map((colName) => {
|
|
||||||
const col = subDataTableColumns.find((c) => c.columnName === colName);
|
|
||||||
const isRequired = config.subDataLookup?.selection?.requiredFields?.includes(colName);
|
|
||||||
return (
|
|
||||||
<div key={colName} className="flex items-center gap-1">
|
|
||||||
<Checkbox
|
|
||||||
id={`required-field-${colName}`}
|
|
||||||
checked={isRequired}
|
|
||||||
onCheckedChange={(checked) => handleRequiredFieldToggle(colName, checked as boolean)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={`required-field-${colName}`} className="cursor-pointer text-xs font-normal">
|
|
||||||
{col?.columnLabel || colName}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-purple-500">이 필드들이 선택되어야 입력이 활성화됩니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필수 조건 */}
|
|
||||||
{(config.subDataLookup?.selection?.requiredFields?.length || 0) > 1 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">필수 조건</Label>
|
|
||||||
<Select
|
|
||||||
value={config.subDataLookup?.selection?.requiredMode || "all"}
|
|
||||||
onValueChange={(v) => handleSubDataLookupChange("selection.requiredMode", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all" className="text-xs">
|
|
||||||
모두 선택해야 함
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="any" className="text-xs">
|
|
||||||
하나만 선택해도 됨
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 조건부 입력 설정 */}
|
|
||||||
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
|
|
||||||
<div className="space-y-3 border-t border-purple-200 pt-3">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">조건부 입력 설정</Label>
|
|
||||||
|
|
||||||
{/* 활성화 대상 필드 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">활성화 대상 필드</Label>
|
|
||||||
<Select
|
|
||||||
value={config.subDataLookup?.conditionalInput?.targetField || "__none__"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleSubDataLookupChange("conditionalInput.targetField", v === "__none__" ? "" : v)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__" className="text-xs">
|
|
||||||
선택 안함
|
|
||||||
</SelectItem>
|
|
||||||
{localFields.length === 0 ? (
|
|
||||||
<SelectItem value="__empty__" disabled className="text-xs text-gray-400">
|
|
||||||
필드 정의에서 먼저 필드를 추가하세요
|
|
||||||
</SelectItem>
|
|
||||||
) : (
|
|
||||||
localFields.map((f) => (
|
|
||||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
|
||||||
{f.label || f.name} ({f.name})
|
|
||||||
</SelectItem>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-[10px] text-purple-500">
|
|
||||||
하위 데이터 선택 후 입력이 활성화될 필드 (예: 출고수량)
|
|
||||||
{localFields.length === 0 && (
|
|
||||||
<span className="ml-1 text-amber-600">* 필드 정의 필요</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 최대값 참조 필드 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">최대값 참조 필드 (선택)</Label>
|
|
||||||
<Select
|
|
||||||
value={config.subDataLookup?.conditionalInput?.maxValueField || "__none__"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
handleSubDataLookupChange("conditionalInput.maxValueField", v === "__none__" ? undefined : v)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="필드 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__" className="text-xs">
|
|
||||||
사용 안함
|
|
||||||
</SelectItem>
|
|
||||||
{subDataTableColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
|
||||||
{col.columnLabel || col.columnName} ({col.columnName})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-[10px] text-purple-500">입력 최대값을 제한할 하위 데이터 필드 (예: 재고수량)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 경고 임계값 */}
|
|
||||||
{config.subDataLookup?.conditionalInput?.maxValueField && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">경고 임계값 (%)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={config.subDataLookup?.conditionalInput?.warningThreshold ?? 90}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleSubDataLookupChange("conditionalInput.warningThreshold", parseInt(e.target.value) || 90)
|
|
||||||
}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-purple-500">이 비율 이상 입력 시 경고 표시 (예: 90%)</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* UI 설정 */}
|
|
||||||
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
|
|
||||||
<div className="space-y-3 border-t border-purple-200 pt-3">
|
|
||||||
<Label className="text-xs font-medium text-purple-700">UI 설정</Label>
|
|
||||||
|
|
||||||
{/* 확장 방식 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">확장 방식</Label>
|
|
||||||
<Select
|
|
||||||
value={config.subDataLookup?.ui?.expandMode || "inline"}
|
|
||||||
onValueChange={(v) => handleSubDataLookupChange("ui.expandMode", v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="inline" className="text-xs">
|
|
||||||
인라인 (행 아래 확장)
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="modal" className="text-xs">
|
|
||||||
모달 (팝업)
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 최대 높이 */}
|
|
||||||
{config.subDataLookup?.ui?.expandMode === "inline" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-[10px] text-purple-600">최대 높이</Label>
|
|
||||||
<Input
|
|
||||||
value={config.subDataLookup?.ui?.maxHeight || "150px"}
|
|
||||||
onChange={(e) => handleSubDataLookupChange("ui.maxHeight", e.target.value)}
|
|
||||||
placeholder="150px"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 요약 정보 표시 */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="sub-data-show-summary"
|
|
||||||
checked={config.subDataLookup?.ui?.showSummary ?? true}
|
|
||||||
onCheckedChange={(checked) => handleSubDataLookupChange("ui.showSummary", checked)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="sub-data-show-summary" className="cursor-pointer text-xs font-normal">
|
|
||||||
요약 정보 표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 설정 요약 */}
|
|
||||||
{config.subDataLookup?.lookup?.tableName && (
|
|
||||||
<div className="rounded bg-purple-100 p-2 text-xs">
|
|
||||||
<p className="font-medium text-purple-800">설정 요약</p>
|
|
||||||
<ul className="mt-1 space-y-0.5 text-purple-700">
|
|
||||||
<li>조회 테이블: {config.subDataLookup?.lookup?.tableName || "-"}</li>
|
|
||||||
<li>연결 컬럼: {config.subDataLookup?.lookup?.linkColumn || "-"}</li>
|
|
||||||
<li>표시 컬럼: {config.subDataLookup?.lookup?.displayColumns?.join(", ") || "-"}</li>
|
|
||||||
<li>필수 선택: {config.subDataLookup?.selection?.requiredFields?.join(", ") || "-"}</li>
|
|
||||||
<li>활성화 필드: {config.subDataLookup?.conditionalInput?.targetField || "-"}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필드 정의 */}
|
{/* 필드 정의 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
|
||||||
<span className="text-xs text-gray-500">드래그하거나 화살표로 순서 변경</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{localFields.map((field, index) => (
|
{localFields.map((field, index) => (
|
||||||
<Card
|
<Card key={`${field.name}-${index}`} className="border-2">
|
||||||
key={`${field.name}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
"border-2 transition-all",
|
|
||||||
draggedFieldIndex === index && "opacity-50 border-blue-400",
|
|
||||||
draggedFieldIndex !== null && draggedFieldIndex !== index && "border-dashed",
|
|
||||||
)}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => handleFieldDragStart(index)}
|
|
||||||
onDragOver={(e) => handleFieldDragOver(e, index)}
|
|
||||||
onDrop={(e) => handleFieldDrop(e, index)}
|
|
||||||
onDragEnd={handleFieldDragEnd}
|
|
||||||
>
|
|
||||||
<CardContent className="space-y-3 pt-4">
|
<CardContent className="space-y-3 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm font-semibold text-gray-700">필드 {index + 1}</span>
|
||||||
{/* 드래그 핸들 */}
|
<Button
|
||||||
<GripVertical className="h-4 w-4 cursor-move text-gray-400 hover:text-gray-600" />
|
type="button"
|
||||||
<span className="text-sm font-semibold text-gray-700">필드 {index + 1}</span>
|
variant="ghost"
|
||||||
</div>
|
size="icon"
|
||||||
<div className="flex items-center gap-1">
|
onClick={() => removeField(index)}
|
||||||
{/* 순서 변경 버튼 */}
|
className="h-6 w-6 text-red-500 hover:bg-red-50"
|
||||||
<Button
|
>
|
||||||
type="button"
|
<X className="h-3 w-3" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
|
||||||
onClick={() => moveFieldUp(index)}
|
|
||||||
disabled={index === 0}
|
|
||||||
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
|
|
||||||
title="위로 이동"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => moveFieldDown(index)}
|
|
||||||
disabled={index === localFields.length - 1}
|
|
||||||
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
|
|
||||||
title="아래로 이동"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeField(index)}
|
|
||||||
className="h-6 w-6 text-red-500 hover:bg-red-50"
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|
|
||||||
|
|
@ -141,4 +141,3 @@ export const useActiveTabOptional = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
interface ScreenContextValue {
|
interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
|
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||||
|
|
||||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
|
@ -40,7 +39,6 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||||
interface ScreenContextProviderProps {
|
interface ScreenContextProviderProps {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuObjid?: number; // 메뉴 OBJID
|
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +49,6 @@ interface ScreenContextProviderProps {
|
||||||
export function ScreenContextProvider({
|
export function ScreenContextProvider({
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
menuObjid,
|
|
||||||
splitPanelPosition,
|
splitPanelPosition,
|
||||||
children,
|
children,
|
||||||
}: ScreenContextProviderProps) {
|
}: ScreenContextProviderProps) {
|
||||||
|
|
@ -115,7 +112,6 @@ export function ScreenContextProvider({
|
||||||
() => ({
|
() => ({
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
menuObjid,
|
|
||||||
splitPanelPosition,
|
splitPanelPosition,
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
|
|
@ -131,7 +127,6 @@ export function ScreenContextProvider({
|
||||||
[
|
[
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
menuObjid,
|
|
||||||
splitPanelPosition,
|
splitPanelPosition,
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -198,4 +198,3 @@ export function applyAutoFillToFormData(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -202,19 +202,14 @@ export class DynamicFormApi {
|
||||||
* 실제 테이블에서 폼 데이터 삭제
|
* 실제 테이블에서 폼 데이터 삭제
|
||||||
* @param id 레코드 ID
|
* @param id 레코드 ID
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
|
||||||
* @returns 삭제 결과
|
* @returns 삭제 결과
|
||||||
*/
|
*/
|
||||||
static async deleteFormDataFromTable(
|
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
|
||||||
id: string | number,
|
|
||||||
tableName: string,
|
|
||||||
screenId?: number
|
|
||||||
): Promise<ApiResponse<void>> {
|
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName, screenId });
|
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
||||||
|
|
||||||
await apiClient.delete(`/dynamic-form/${id}`, {
|
await apiClient.delete(`/dynamic-form/${id}`, {
|
||||||
data: { tableName, screenId },
|
data: { tableName },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
console.log("✅ 실제 테이블에서 폼 데이터 삭제 성공");
|
||||||
|
|
@ -561,192 +556,6 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================
|
|
||||||
// 마스터-디테일 엑셀 API
|
|
||||||
// ================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 관계 정보 조회
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @returns 마스터-디테일 관계 정보 (null이면 마스터-디테일 구조 아님)
|
|
||||||
*/
|
|
||||||
static async getMasterDetailRelation(screenId: number): Promise<ApiResponse<MasterDetailRelation | null>> {
|
|
||||||
try {
|
|
||||||
console.log("🔍 마스터-디테일 관계 조회:", screenId);
|
|
||||||
|
|
||||||
const response = await apiClient.get(`/data/master-detail/relation/${screenId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data?.data || null,
|
|
||||||
message: response.data?.message || "조회 완료",
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ 마스터-디테일 관계 조회 실패:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
message: error.response?.data?.message || error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @param filters 필터 조건
|
|
||||||
* @returns JOIN된 플랫 데이터
|
|
||||||
*/
|
|
||||||
static async getMasterDetailDownloadData(
|
|
||||||
screenId: number,
|
|
||||||
filters?: Record<string, any>
|
|
||||||
): Promise<ApiResponse<MasterDetailDownloadData>> {
|
|
||||||
try {
|
|
||||||
console.log("📥 마스터-디테일 다운로드 데이터 조회:", { screenId, filters });
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/data/master-detail/download`, {
|
|
||||||
screenId,
|
|
||||||
filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data?.data,
|
|
||||||
message: "데이터 조회 완료",
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ 마스터-디테일 다운로드 실패:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.response?.data?.message || error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 엑셀 업로드
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @param data 엑셀에서 읽은 플랫 데이터
|
|
||||||
* @returns 업로드 결과
|
|
||||||
*/
|
|
||||||
static async uploadMasterDetailData(
|
|
||||||
screenId: number,
|
|
||||||
data: Record<string, any>[]
|
|
||||||
): Promise<ApiResponse<MasterDetailUploadResult>> {
|
|
||||||
try {
|
|
||||||
console.log("📤 마스터-디테일 업로드:", { screenId, rowCount: data.length });
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/data/master-detail/upload`, {
|
|
||||||
screenId,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: response.data?.success,
|
|
||||||
data: response.data?.data,
|
|
||||||
message: response.data?.message,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ 마스터-디테일 업로드 실패:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.response?.data?.message || error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마스터-디테일 간단 모드 엑셀 업로드
|
|
||||||
* - 마스터 정보는 UI에서 선택
|
|
||||||
* - 디테일 정보만 엑셀에서 업로드
|
|
||||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
|
||||||
* @param screenId 화면 ID
|
|
||||||
* @param detailData 디테일 데이터 배열
|
|
||||||
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
|
||||||
* @param numberingRuleId 채번 규칙 ID (optional)
|
|
||||||
* @param afterUploadFlowId 업로드 후 실행할 제어 ID (optional, 하위 호환성)
|
|
||||||
* @param afterUploadFlows 업로드 후 실행할 제어 목록 (optional)
|
|
||||||
* @returns 업로드 결과
|
|
||||||
*/
|
|
||||||
static async uploadMasterDetailSimple(
|
|
||||||
screenId: number,
|
|
||||||
detailData: Record<string, any>[],
|
|
||||||
masterFieldValues: Record<string, any>,
|
|
||||||
numberingRuleId?: string,
|
|
||||||
afterUploadFlowId?: string,
|
|
||||||
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
|
||||||
): Promise<ApiResponse<MasterDetailSimpleUploadResult>> {
|
|
||||||
try {
|
|
||||||
console.log("📤 마스터-디테일 간단 모드 업로드:", {
|
|
||||||
screenId,
|
|
||||||
detailRowCount: detailData.length,
|
|
||||||
masterFieldValues,
|
|
||||||
numberingRuleId,
|
|
||||||
afterUploadFlows: afterUploadFlows?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/data/master-detail/upload-simple`, {
|
|
||||||
screenId,
|
|
||||||
detailData,
|
|
||||||
masterFieldValues,
|
|
||||||
numberingRuleId,
|
|
||||||
afterUploadFlowId,
|
|
||||||
afterUploadFlows,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: response.data?.success,
|
|
||||||
data: response.data?.data,
|
|
||||||
message: response.data?.message,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("❌ 마스터-디테일 간단 모드 업로드 실패:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error.response?.data?.message || error.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마스터-디테일 관계 타입
|
|
||||||
export interface MasterDetailRelation {
|
|
||||||
masterTable: string;
|
|
||||||
detailTable: string;
|
|
||||||
masterKeyColumn: string;
|
|
||||||
detailFkColumn: string;
|
|
||||||
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
|
||||||
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마스터-디테일 다운로드 데이터 타입
|
|
||||||
export interface MasterDetailDownloadData {
|
|
||||||
headers: string[];
|
|
||||||
columns: string[];
|
|
||||||
data: Record<string, any>[];
|
|
||||||
masterColumns: string[];
|
|
||||||
detailColumns: string[];
|
|
||||||
joinKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마스터-디테일 업로드 결과 타입
|
|
||||||
export interface MasterDetailUploadResult {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
masterUpdated: number;
|
|
||||||
detailInserted: number;
|
|
||||||
detailDeleted: number;
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 마스터-디테일 간단 모드 업로드 결과 타입
|
|
||||||
export interface MasterDetailSimpleUploadResult {
|
|
||||||
success: boolean;
|
|
||||||
masterInserted: number;
|
|
||||||
detailInserted: number;
|
|
||||||
generatedKey: string; // 생성된 마스터 키
|
|
||||||
errors?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편의를 위한 기본 export
|
// 편의를 위한 기본 export
|
||||||
|
|
|
||||||
|
|
@ -77,26 +77,21 @@ export const entityJoinApi = {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
companyCodeOverride?: string; // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
const autoFilter: {
|
const autoFilter = {
|
||||||
enabled: boolean;
|
|
||||||
filterColumn: string;
|
|
||||||
userField: string;
|
|
||||||
companyCodeOverride?: string;
|
|
||||||
} = {
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
filterColumn: "company_code",
|
filterColumn: "company_code",
|
||||||
userField: "companyCode",
|
userField: "companyCode",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 프리뷰 모드에서 회사 코드 오버라이드 (최고 관리자만 백엔드에서 허용)
|
|
||||||
if (params.companyCodeOverride) {
|
|
||||||
autoFilter.companyCodeOverride = params.companyCodeOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
||||||
params: {
|
params: {
|
||||||
page: params.page,
|
page: params.page,
|
||||||
|
|
@ -107,9 +102,10 @@ export const entityJoinApi = {
|
||||||
search: params.search ? JSON.stringify(params.search) : undefined,
|
search: params.search ? JSON.stringify(params.search) : undefined,
|
||||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 (오버라이드 포함)
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||||
|
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -105,18 +105,6 @@ export const screenApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 화면 수정 (이름, 설명 등)
|
|
||||||
updateScreen: async (
|
|
||||||
screenId: number,
|
|
||||||
data: {
|
|
||||||
screenName?: string;
|
|
||||||
description?: string;
|
|
||||||
tableName?: string;
|
|
||||||
}
|
|
||||||
): Promise<void> => {
|
|
||||||
await apiClient.put(`/screen-management/screens/${screenId}`, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 화면 삭제 (휴지통으로 이동)
|
// 화면 삭제 (휴지통으로 이동)
|
||||||
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
|
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
|
||||||
await apiClient.delete(`/screen-management/screens/${screenId}`, {
|
await apiClient.delete(`/screen-management/screens/${screenId}`, {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ export interface ColumnTypeInfo {
|
||||||
dataType: string;
|
dataType: string;
|
||||||
dbType: string;
|
dbType: string;
|
||||||
webType: string;
|
webType: string;
|
||||||
inputType?: string; // text, number, entity, code, select, date, checkbox 등
|
inputType?: "direct" | "auto";
|
||||||
detailSettings: string;
|
detailSettings: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isNullable: string;
|
isNullable: string;
|
||||||
|
|
@ -39,11 +39,11 @@ export interface TableInfo {
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 설정 타입 (백엔드 API와 동일한 필드명 사용)
|
// 컬럼 설정 타입
|
||||||
export interface ColumnSettings {
|
export interface ColumnSettings {
|
||||||
columnName?: string;
|
columnName?: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
inputType: string; // 백엔드에서 inputType으로 받음
|
webType: string;
|
||||||
detailSettings: string;
|
detailSettings: string;
|
||||||
codeCategory: string;
|
codeCategory: string;
|
||||||
codeValue: string;
|
codeValue: string;
|
||||||
|
|
|
||||||
|
|
@ -281,12 +281,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||||
const fieldName = (component as any).columnName || component.id;
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
|
||||||
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
|
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||||
let currentValue;
|
let currentValue;
|
||||||
if (componentType === "modal-repeater-table" ||
|
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") {
|
||||||
componentType === "repeat-screen-modal" ||
|
// EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||||
componentType === "selected-items-detail-input") {
|
|
||||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
|
||||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||||
} else {
|
} else {
|
||||||
currentValue = formData?.[fieldName] || "";
|
currentValue = formData?.[fieldName] || "";
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
|
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
|
||||||
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
|
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
|
||||||
|
|
||||||
|
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
|
||||||
|
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const newData = splitPanelContext?.selectedLeftData ?? null;
|
||||||
|
setTrackedSelectedLeftData(newData);
|
||||||
|
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
|
||||||
|
// label: component.label,
|
||||||
|
// hasData: !!newData,
|
||||||
|
// dataKeys: newData ? Object.keys(newData) : [],
|
||||||
|
// });
|
||||||
|
}, [splitPanelContext?.selectedLeftData, component.label]);
|
||||||
|
|
||||||
// modalDataStore 상태 구독 (실시간 업데이트)
|
// modalDataStore 상태 구독 (실시간 업데이트)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const actionConfig = component.componentConfig?.action;
|
const actionConfig = component.componentConfig?.action;
|
||||||
|
|
@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 2. 분할 패널 좌측 선택 데이터 확인
|
// 2. 분할 패널 좌측 선택 데이터 확인
|
||||||
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
|
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
|
||||||
// SplitPanelContext에서 확인
|
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
|
||||||
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
|
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
|
||||||
if (!hasSelection) {
|
if (!hasSelection) {
|
||||||
hasSelection = true;
|
hasSelection = true;
|
||||||
selectionCount = 1;
|
selectionCount = 1;
|
||||||
|
|
@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
selectionCount,
|
selectionCount,
|
||||||
selectionSource,
|
selectionSource,
|
||||||
hasSplitPanelContext: !!splitPanelContext,
|
hasSplitPanelContext: !!splitPanelContext,
|
||||||
selectedLeftData: splitPanelContext?.selectedLeftData,
|
trackedSelectedLeftData: trackedSelectedLeftData,
|
||||||
selectedRowsData: selectedRowsData?.length,
|
selectedRowsData: selectedRowsData?.length,
|
||||||
selectedRows: selectedRows?.length,
|
selectedRows: selectedRows?.length,
|
||||||
flowSelectedData: flowSelectedData?.length,
|
flowSelectedData: flowSelectedData?.length,
|
||||||
|
|
@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
component.label,
|
component.label,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
splitPanelContext?.selectedLeftData,
|
trackedSelectedLeftData,
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
splitPanelContext,
|
splitPanelContext,
|
||||||
modalStoreData,
|
modalStoreData,
|
||||||
|
|
@ -495,50 +509,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
||||||
} as ButtonPrimaryConfig;
|
} as ButtonPrimaryConfig;
|
||||||
|
|
||||||
// 🎨 동적 색상 설정 (webTypeConfig 우선, 레거시 style.labelColor 지원)
|
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||||
const getButtonBackgroundColor = () => {
|
const getLabelColor = () => {
|
||||||
// 1순위: webTypeConfig.backgroundColor (화면설정 모달에서 저장)
|
|
||||||
if (component.webTypeConfig?.backgroundColor) {
|
|
||||||
return component.webTypeConfig.backgroundColor;
|
|
||||||
}
|
|
||||||
// 2순위: componentConfig.backgroundColor
|
|
||||||
if (componentConfig.backgroundColor) {
|
|
||||||
return componentConfig.backgroundColor;
|
|
||||||
}
|
|
||||||
// 3순위: style.backgroundColor
|
|
||||||
if (component.style?.backgroundColor) {
|
|
||||||
return component.style.backgroundColor;
|
|
||||||
}
|
|
||||||
// 4순위: style.labelColor (레거시)
|
|
||||||
if (component.style?.labelColor) {
|
|
||||||
return component.style.labelColor;
|
|
||||||
}
|
|
||||||
// 기본값: 삭제 버튼이면 빨강, 아니면 파랑
|
|
||||||
if (isDeleteAction()) {
|
if (isDeleteAction()) {
|
||||||
return "#ef4444"; // 빨간색 (Tailwind red-500)
|
return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500)
|
||||||
}
|
}
|
||||||
return "#3b82f6"; // 파란색 (Tailwind blue-500)
|
return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary)
|
||||||
};
|
};
|
||||||
|
|
||||||
const getButtonTextColor = () => {
|
const buttonColor = getLabelColor();
|
||||||
// 1순위: webTypeConfig.textColor (화면설정 모달에서 저장)
|
|
||||||
if (component.webTypeConfig?.textColor) {
|
|
||||||
return component.webTypeConfig.textColor;
|
|
||||||
}
|
|
||||||
// 2순위: componentConfig.textColor
|
|
||||||
if (componentConfig.textColor) {
|
|
||||||
return componentConfig.textColor;
|
|
||||||
}
|
|
||||||
// 3순위: style.color
|
|
||||||
if (component.style?.color) {
|
|
||||||
return component.style.color;
|
|
||||||
}
|
|
||||||
// 기본값: 흰색
|
|
||||||
return "#ffffff";
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonColor = getButtonBackgroundColor();
|
|
||||||
const buttonTextColor = getButtonTextColor();
|
|
||||||
|
|
||||||
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
|
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
|
||||||
const processedConfig = { ...componentConfig };
|
const processedConfig = { ...componentConfig };
|
||||||
|
|
@ -1150,7 +1129,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
} : undefined,
|
} : undefined,
|
||||||
} as ButtonActionContext;
|
} as ButtonActionContext;
|
||||||
|
|
||||||
// 확인이 필요한 액션인지 확인 (save/delete만 확인 다이얼로그 표시)
|
// 확인이 필요한 액션인지 확인
|
||||||
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
||||||
// 확인 다이얼로그 표시
|
// 확인 다이얼로그 표시
|
||||||
setPendingAction({
|
setPendingAction({
|
||||||
|
|
@ -1286,8 +1265,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
minHeight: "40px",
|
minHeight: "40px",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
|
||||||
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원
|
color: finalDisabled ? "#9ca3af" : "white",
|
||||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,6 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
||||||
// 🆕 연관 데이터 버튼 컴포넌트
|
// 🆕 연관 데이터 버튼 컴포넌트
|
||||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||||
|
|
||||||
// 🆕 피벗 그리드 컴포넌트
|
|
||||||
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -180,11 +180,8 @@ export function ModalRepeaterTableComponent({
|
||||||
filterCondition: propFilterCondition,
|
filterCondition: propFilterCondition,
|
||||||
companyCode: propCompanyCode,
|
companyCode: propCompanyCode,
|
||||||
|
|
||||||
// 🆕 그룹 데이터 (EditModal에서 전달, 같은 그룹의 여러 품목)
|
|
||||||
groupedData,
|
|
||||||
|
|
||||||
...props
|
...props
|
||||||
}: ModalRepeaterTableComponentProps & { groupedData?: Record<string, any>[] }) {
|
}: ModalRepeaterTableComponentProps) {
|
||||||
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
|
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -211,16 +208,9 @@ export function ModalRepeaterTableComponent({
|
||||||
// 모달 필터 설정
|
// 모달 필터 설정
|
||||||
const modalFilters = componentConfig?.modalFilters || [];
|
const modalFilters = componentConfig?.modalFilters || [];
|
||||||
|
|
||||||
// ✅ value는 groupedData 우선, 없으면 formData[columnName], 없으면 prop 사용
|
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
const columnName = component?.columnName;
|
const columnName = component?.columnName;
|
||||||
|
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||||
// 🆕 groupedData가 전달되면 (EditModal에서 그룹 조회 결과) 우선 사용
|
|
||||||
const externalValue = (() => {
|
|
||||||
if (groupedData && groupedData.length > 0) {
|
|
||||||
return groupedData;
|
|
||||||
}
|
|
||||||
return (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
|
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
|
||||||
const isEmptyRow = (item: any): boolean => {
|
const isEmptyRow = (item: any): boolean => {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,73 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react";
|
import React from "react";
|
||||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import { PivotGridComponent } from "./PivotGridComponent";
|
import { PivotGridComponent } from "./PivotGridComponent";
|
||||||
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
import { PivotGridConfigPanel } from "./PivotGridConfigPanel";
|
||||||
import { PivotFieldConfig } from "./types";
|
import { PivotFieldConfig } from "./types";
|
||||||
import { dataApi } from "@/lib/api/data";
|
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
// ==================== 에러 경계 ====================
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
hasError: boolean;
|
|
||||||
error?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PivotGridErrorBoundary extends Component<
|
|
||||||
{ children: ReactNode; onReset?: () => void },
|
|
||||||
ErrorBoundaryState
|
|
||||||
> {
|
|
||||||
constructor(props: { children: ReactNode; onReset?: () => void }) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error("🔴 [PivotGrid] 렌더링 에러:", error);
|
|
||||||
console.error("🔴 [PivotGrid] 에러 정보:", errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleReset = () => {
|
|
||||||
this.setState({ hasError: false, error: undefined });
|
|
||||||
this.props.onReset?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-center border border-destructive/50 rounded-lg bg-destructive/5">
|
|
||||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
|
||||||
<h3 className="text-sm font-medium text-destructive mb-1">
|
|
||||||
피벗 그리드 오류
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mb-3 max-w-md">
|
|
||||||
{this.state.error?.message || "알 수 없는 오류가 발생했습니다."}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={this.handleReset}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
다시 시도
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 샘플 데이터 (미리보기용) ====================
|
// ==================== 샘플 데이터 (미리보기용) ====================
|
||||||
|
|
||||||
|
|
@ -156,63 +95,43 @@ const PivotGridWrapper: React.FC<any> = (props) => {
|
||||||
const configFields = componentConfig.fields || props.fields;
|
const configFields = componentConfig.fields || props.fields;
|
||||||
const configData = props.data;
|
const configData = props.data;
|
||||||
|
|
||||||
// 🆕 테이블에서 데이터 자동 로딩
|
// 디버깅 로그
|
||||||
const [loadedData, setLoadedData] = useState<any[]>([]);
|
console.log("🔷 PivotGridWrapper props:", {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
isDesignMode: props.isDesignMode,
|
||||||
|
isInteractive: props.isInteractive,
|
||||||
useEffect(() => {
|
hasComponentConfig: !!props.componentConfig,
|
||||||
const loadTableData = async () => {
|
hasConfig: !!props.config,
|
||||||
const tableName = componentConfig.dataSource?.tableName;
|
hasData: !!configData,
|
||||||
|
dataLength: configData?.length,
|
||||||
// 데이터가 이미 있거나, 테이블명이 없으면 로딩하지 않음
|
hasFields: !!configFields,
|
||||||
if (configData || !tableName || props.isDesignMode) {
|
fieldsLength: configFields?.length,
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await dataApi.getTableData(tableName, {
|
|
||||||
page: 1,
|
|
||||||
size: 10000, // 피벗 분석용 대량 데이터
|
|
||||||
});
|
|
||||||
|
|
||||||
// dataApi.getTableData는 { data, total, page, size, totalPages } 구조
|
|
||||||
if (response.data && Array.isArray(response.data)) {
|
|
||||||
setLoadedData(response.data);
|
|
||||||
} else {
|
|
||||||
console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음");
|
|
||||||
setLoadedData([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ [PivotGrid] 데이터 로딩 에러:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadTableData();
|
|
||||||
}, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]);
|
|
||||||
|
|
||||||
// 디자인 모드 판단:
|
// 디자인 모드 판단:
|
||||||
// 1. isDesignMode === true
|
// 1. isDesignMode === true
|
||||||
// 2. isInteractive === false (편집 모드)
|
// 2. isInteractive === false (편집 모드)
|
||||||
|
// 3. 데이터가 없는 경우
|
||||||
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
const isDesignMode = props.isDesignMode === true || props.isInteractive === false;
|
||||||
|
const hasValidData = configData && Array.isArray(configData) && configData.length > 0;
|
||||||
// 🆕 실제 데이터 우선순위: props.data > loadedData > 샘플 데이터
|
|
||||||
const actualData = configData || loadedData;
|
|
||||||
const hasValidData = actualData && Array.isArray(actualData) && actualData.length > 0;
|
|
||||||
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
const hasValidFields = configFields && Array.isArray(configFields) && configFields.length > 0;
|
||||||
|
|
||||||
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
// 디자인 모드이거나 데이터가 없으면 샘플 데이터 사용
|
||||||
const usePreviewData = isDesignMode || (!hasValidData && !isLoading);
|
const usePreviewData = isDesignMode || !hasValidData;
|
||||||
|
|
||||||
// 최종 데이터/필드 결정
|
// 최종 데이터/필드 결정
|
||||||
const finalData = usePreviewData ? SAMPLE_DATA : actualData;
|
const finalData = usePreviewData ? SAMPLE_DATA : configData;
|
||||||
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
const finalFields = hasValidFields ? configFields : SAMPLE_FIELDS;
|
||||||
const finalTitle = usePreviewData
|
const finalTitle = usePreviewData
|
||||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||||
: (componentConfig.title || props.title);
|
: (componentConfig.title || props.title);
|
||||||
|
|
||||||
|
console.log("🔷 PivotGridWrapper final:", {
|
||||||
|
isDesignMode,
|
||||||
|
usePreviewData,
|
||||||
|
finalDataLength: finalData?.length,
|
||||||
|
finalFieldsLength: finalFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
// 총계 설정
|
// 총계 설정
|
||||||
const totalsConfig = componentConfig.totals || props.totals || {
|
const totalsConfig = componentConfig.totals || props.totals || {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
|
|
@ -221,39 +140,24 @@ const PivotGridWrapper: React.FC<any> = (props) => {
|
||||||
showColumnTotals: true,
|
showColumnTotals: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 로딩 중 표시
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">데이터 로딩 중...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함
|
|
||||||
return (
|
return (
|
||||||
<PivotGridErrorBoundary>
|
<PivotGridComponent
|
||||||
<PivotGridComponent
|
title={finalTitle}
|
||||||
title={finalTitle}
|
data={finalData}
|
||||||
data={finalData}
|
fields={finalFields}
|
||||||
fields={finalFields}
|
totals={totalsConfig}
|
||||||
totals={totalsConfig}
|
style={componentConfig.style || props.style}
|
||||||
style={componentConfig.style || props.style}
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
chart={componentConfig.chart || props.chart}
|
||||||
chart={componentConfig.chart || props.chart}
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
height={componentConfig.height || props.height || "400px"}
|
||||||
height="100%"
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
onCellClick={props.onCellClick}
|
||||||
onCellClick={props.onCellClick}
|
onCellDoubleClick={props.onCellDoubleClick}
|
||||||
onCellDoubleClick={props.onCellDoubleClick}
|
onFieldDrop={props.onFieldDrop}
|
||||||
onFieldDrop={props.onFieldDrop}
|
onExpandChange={props.onExpandChange}
|
||||||
onExpandChange={props.onExpandChange}
|
/>
|
||||||
/>
|
|
||||||
</PivotGridErrorBoundary>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -319,6 +223,18 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||||
const componentConfig = props.componentConfig || props.config || {};
|
const componentConfig = props.componentConfig || props.config || {};
|
||||||
const configFields = componentConfig.fields || props.fields;
|
const configFields = componentConfig.fields || props.fields;
|
||||||
const configData = props.data;
|
const configData = props.data;
|
||||||
|
|
||||||
|
// 디버깅 로그
|
||||||
|
console.log("🔷 PivotGridRenderer props:", {
|
||||||
|
isDesignMode: props.isDesignMode,
|
||||||
|
isInteractive: props.isInteractive,
|
||||||
|
hasComponentConfig: !!props.componentConfig,
|
||||||
|
hasConfig: !!props.config,
|
||||||
|
hasData: !!configData,
|
||||||
|
dataLength: configData?.length,
|
||||||
|
hasFields: !!configFields,
|
||||||
|
fieldsLength: configFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
// 디자인 모드 판단:
|
// 디자인 모드 판단:
|
||||||
// 1. isDesignMode === true
|
// 1. isDesignMode === true
|
||||||
|
|
@ -338,6 +254,13 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||||
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)"
|
||||||
: (componentConfig.title || props.title);
|
: (componentConfig.title || props.title);
|
||||||
|
|
||||||
|
console.log("🔷 PivotGridRenderer final:", {
|
||||||
|
isDesignMode,
|
||||||
|
usePreviewData,
|
||||||
|
finalDataLength: finalData?.length,
|
||||||
|
finalFieldsLength: finalFields?.length,
|
||||||
|
});
|
||||||
|
|
||||||
// 총계 설정
|
// 총계 설정
|
||||||
const totalsConfig = componentConfig.totals || props.totals || {
|
const totalsConfig = componentConfig.totals || props.totals || {
|
||||||
showRowGrandTotals: true,
|
showRowGrandTotals: true,
|
||||||
|
|
@ -356,7 +279,7 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer {
|
||||||
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
fieldChooser={componentConfig.fieldChooser || props.fieldChooser}
|
||||||
chart={componentConfig.chart || props.chart}
|
chart={componentConfig.chart || props.chart}
|
||||||
allowExpandAll={componentConfig.allowExpandAll !== false}
|
allowExpandAll={componentConfig.allowExpandAll !== false}
|
||||||
height="100%"
|
height={componentConfig.height || props.height || "400px"}
|
||||||
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
maxHeight={componentConfig.maxHeight || props.maxHeight}
|
||||||
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
exportConfig={componentConfig.exportConfig || props.exportConfig || { excel: true }}
|
||||||
onCellClick={props.onCellClick}
|
onCellClick={props.onCellClick}
|
||||||
|
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PivotGrid 컨텍스트 메뉴 컴포넌트
|
|
||||||
* 우클릭 시 정렬, 필터, 확장/축소 등의 옵션 제공
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import {
|
|
||||||
ArrowUpAZ,
|
|
||||||
ArrowDownAZ,
|
|
||||||
Filter,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Copy,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
BarChart3,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { PivotFieldConfig, AggregationType } from "../types";
|
|
||||||
|
|
||||||
interface PivotContextMenuProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
// 현재 컨텍스트 정보
|
|
||||||
cellType: "header" | "data" | "rowHeader" | "columnHeader";
|
|
||||||
field?: PivotFieldConfig;
|
|
||||||
rowPath?: string[];
|
|
||||||
columnPath?: string[];
|
|
||||||
value?: any;
|
|
||||||
// 콜백
|
|
||||||
onSort?: (field: string, direction: "asc" | "desc") => void;
|
|
||||||
onFilter?: (field: string) => void;
|
|
||||||
onExpand?: (path: string[]) => void;
|
|
||||||
onCollapse?: (path: string[]) => void;
|
|
||||||
onExpandAll?: () => void;
|
|
||||||
onCollapseAll?: () => void;
|
|
||||||
onCopy?: (value: any) => void;
|
|
||||||
onHideField?: (field: string) => void;
|
|
||||||
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
|
|
||||||
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
|
|
||||||
children,
|
|
||||||
cellType,
|
|
||||||
field,
|
|
||||||
rowPath,
|
|
||||||
columnPath,
|
|
||||||
value,
|
|
||||||
onSort,
|
|
||||||
onFilter,
|
|
||||||
onExpand,
|
|
||||||
onCollapse,
|
|
||||||
onExpandAll,
|
|
||||||
onCollapseAll,
|
|
||||||
onCopy,
|
|
||||||
onHideField,
|
|
||||||
onChangeSummary,
|
|
||||||
onDrillDown,
|
|
||||||
}) => {
|
|
||||||
const handleCopy = () => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
navigator.clipboard.writeText(String(value));
|
|
||||||
onCopy?.(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent className="w-48">
|
|
||||||
{/* 정렬 옵션 (헤더에서만) */}
|
|
||||||
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
|
|
||||||
<>
|
|
||||||
<ContextMenuSub>
|
|
||||||
<ContextMenuSubTrigger>
|
|
||||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
|
||||||
정렬
|
|
||||||
</ContextMenuSubTrigger>
|
|
||||||
<ContextMenuSubContent>
|
|
||||||
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
|
|
||||||
<ArrowUpAZ className="mr-2 h-4 w-4" />
|
|
||||||
오름차순
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
|
|
||||||
<ArrowDownAZ className="mr-2 h-4 w-4" />
|
|
||||||
내림차순
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuSubContent>
|
|
||||||
</ContextMenuSub>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 확장/축소 옵션 */}
|
|
||||||
{(cellType === "rowHeader" || cellType === "columnHeader") && (
|
|
||||||
<>
|
|
||||||
{rowPath && rowPath.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
|
|
||||||
<ChevronDown className="mr-2 h-4 w-4" />
|
|
||||||
확장
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
|
|
||||||
<ChevronRight className="mr-2 h-4 w-4" />
|
|
||||||
축소
|
|
||||||
</ContextMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<ContextMenuItem onClick={onExpandAll}>
|
|
||||||
<ChevronDown className="mr-2 h-4 w-4" />
|
|
||||||
전체 확장
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={onCollapseAll}>
|
|
||||||
<ChevronRight className="mr-2 h-4 w-4" />
|
|
||||||
전체 축소
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 필터 옵션 */}
|
|
||||||
{field && onFilter && (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem onClick={() => onFilter(field.field)}>
|
|
||||||
<Filter className="mr-2 h-4 w-4" />
|
|
||||||
필터
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 집계 함수 변경 (데이터 필드에서만) */}
|
|
||||||
{cellType === "data" && field && onChangeSummary && (
|
|
||||||
<>
|
|
||||||
<ContextMenuSub>
|
|
||||||
<ContextMenuSubTrigger>
|
|
||||||
<BarChart3 className="mr-2 h-4 w-4" />
|
|
||||||
집계 함수
|
|
||||||
</ContextMenuSubTrigger>
|
|
||||||
<ContextMenuSubContent>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onChangeSummary(field.field, "sum")}
|
|
||||||
>
|
|
||||||
합계
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onChangeSummary(field.field, "count")}
|
|
||||||
>
|
|
||||||
개수
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onChangeSummary(field.field, "avg")}
|
|
||||||
>
|
|
||||||
평균
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onChangeSummary(field.field, "min")}
|
|
||||||
>
|
|
||||||
최소
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => onChangeSummary(field.field, "max")}
|
|
||||||
>
|
|
||||||
최대
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuSubContent>
|
|
||||||
</ContextMenuSub>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 드릴다운 (데이터 셀에서만) */}
|
|
||||||
{cellType === "data" && rowPath && columnPath && onDrillDown && (
|
|
||||||
<>
|
|
||||||
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
상세 데이터 보기
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 필드 숨기기 */}
|
|
||||||
{field && onHideField && (
|
|
||||||
<ContextMenuItem onClick={() => onHideField(field.field)}>
|
|
||||||
<EyeOff className="mr-2 h-4 w-4" />
|
|
||||||
필드 숨기기
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 복사 */}
|
|
||||||
<ContextMenuItem onClick={handleCopy}>
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
복사
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PivotContextMenu;
|
|
||||||
|
|
||||||
|
|
@ -94,15 +94,6 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
|
||||||
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
|
|
||||||
{ value: "none", label: "그룹 없음" },
|
|
||||||
{ value: "year", label: "년" },
|
|
||||||
{ value: "quarter", label: "분기" },
|
|
||||||
{ value: "month", label: "월" },
|
|
||||||
{ value: "week", label: "주" },
|
|
||||||
{ value: "day", label: "일" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||||
string: <Type className="h-3.5 w-3.5" />,
|
string: <Type className="h-3.5 w-3.5" />,
|
||||||
number: <Hash className="h-3.5 w-3.5" />,
|
number: <Hash className="h-3.5 w-3.5" />,
|
||||||
|
|
@ -267,9 +258,11 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||||
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
const existingConfig = selectedFields.find((f) => f.field === field.field);
|
||||||
|
|
||||||
if (area === "none") {
|
if (area === "none") {
|
||||||
// 필드 완전 제거 (visible: false 대신 배열에서 제거)
|
// 필드 제거 또는 숨기기
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
const newFields = selectedFields.filter((f) => f.field !== field.field);
|
const newFields = selectedFields.map((f) =>
|
||||||
|
f.field === field.field ? { ...f, visible: false } : f
|
||||||
|
);
|
||||||
onFieldsChange(newFields);
|
onFieldsChange(newFields);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -399,7 +392,7 @@ export const FieldChooser: React.FC<FieldChooserProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 목록 */}
|
{/* 필드 목록 */}
|
||||||
<ScrollArea className="flex-1 -mx-6 px-6 max-h-[40vh] overflow-y-auto">
|
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||||
<div className="space-y-2 py-2">
|
<div className="space-y-2 py-2">
|
||||||
{filteredFields.length === 0 ? (
|
{filteredFields.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FieldPanel 컴포넌트
|
* FieldPanel 컴포넌트
|
||||||
* 피벗 그리드 상단의 필드 배치 영역 (열, 행, 데이터)
|
* 피벗 그리드 상단의 필드 배치 영역 (필터, 열, 행, 데이터)
|
||||||
* 드래그 앤 드롭으로 필드 재배치 가능
|
* 드래그 앤 드롭으로 필드 재배치 가능
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
horizontalListSortingStrategy,
|
horizontalListSortingStrategy,
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PivotFieldConfig, PivotAreaType } from "../types";
|
import { PivotFieldConfig, PivotAreaType } from "../types";
|
||||||
|
|
@ -245,31 +244,22 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||||
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
const areaFields = fields.filter((f) => f.area === area && f.visible !== false);
|
||||||
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
const fieldIds = areaFields.map((f) => `${area}-${f.field}`);
|
||||||
|
|
||||||
// 🆕 드롭 가능 영역 설정
|
|
||||||
const { setNodeRef, isOver: isOverDroppable } = useDroppable({
|
|
||||||
id: area, // "filter", "column", "row", "data"
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalIsOver = isOver || isOverDroppable;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 min-h-[60px] rounded border-2 border-dashed p-2",
|
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
|
||||||
"transition-all duration-200",
|
"transition-colors duration-200",
|
||||||
config.color,
|
config.color,
|
||||||
finalIsOver && "border-primary bg-primary/10 scale-[1.02]",
|
isOver && "border-primary bg-primary/5"
|
||||||
areaFields.length === 0 && "border-2" // 빈 영역일 때 테두리 강조
|
|
||||||
)}
|
)}
|
||||||
data-area={area}
|
data-area={area}
|
||||||
>
|
>
|
||||||
{/* 영역 헤더 */}
|
{/* 영역 헤더 */}
|
||||||
<div className="flex items-center gap-1 mb-1.5 text-xs font-semibold text-muted-foreground">
|
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
|
||||||
{icon}
|
{icon}
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
{areaFields.length > 0 && (
|
{areaFields.length > 0 && (
|
||||||
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
<span className="text-[10px] bg-muted px-1 rounded">
|
||||||
{areaFields.length}
|
{areaFields.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -277,16 +267,11 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
|
||||||
|
|
||||||
{/* 필드 목록 */}
|
{/* 필드 목록 */}
|
||||||
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
|
||||||
<div className="flex flex-wrap gap-1 min-h-[28px] relative">
|
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
|
||||||
{areaFields.length === 0 ? (
|
{areaFields.length === 0 ? (
|
||||||
<div
|
<span className="text-xs text-muted-foreground/50 italic">
|
||||||
className="flex items-center justify-center w-full py-1 pointer-events-none"
|
필드를 여기로 드래그
|
||||||
style={{ pointerEvents: 'none' }}
|
</span>
|
||||||
>
|
|
||||||
<span className="text-xs text-muted-foreground/70 italic font-medium">
|
|
||||||
← 필드를 여기로 드래그하세요
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
areaFields.map((field) => (
|
areaFields.map((field) => (
|
||||||
<SortableFieldChip
|
<SortableFieldChip
|
||||||
|
|
@ -354,16 +339,8 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 드롭 영역 감지 (영역 자체의 ID를 우선 확인)
|
// 드롭 영역 감지
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
// 1. overId가 영역 자체인 경우 (filter, column, row, data)
|
|
||||||
if (["filter", "column", "row", "data"].includes(overId)) {
|
|
||||||
setOverArea(overId as PivotAreaType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. overId가 필드인 경우 (예: row-part_name)
|
|
||||||
const targetArea = overId.split("-")[0] as PivotAreaType;
|
const targetArea = overId.split("-")[0] as PivotAreaType;
|
||||||
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
if (["filter", "column", "row", "data"].includes(targetArea)) {
|
||||||
setOverArea(targetArea);
|
setOverArea(targetArea);
|
||||||
|
|
@ -373,13 +350,10 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
// 드래그 종료
|
// 드래그 종료
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장
|
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setOverArea(null);
|
setOverArea(null);
|
||||||
|
|
||||||
if (!over) {
|
if (!over) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeId = active.id as string;
|
const activeId = active.id as string;
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
@ -389,16 +363,7 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
PivotAreaType,
|
PivotAreaType,
|
||||||
string
|
string
|
||||||
];
|
];
|
||||||
|
const [targetArea] = overId.split("-") as [PivotAreaType, string];
|
||||||
// targetArea 결정: handleDragOver에서 감지한 영역 우선 사용
|
|
||||||
let targetArea: PivotAreaType;
|
|
||||||
if (currentOverArea) {
|
|
||||||
targetArea = currentOverArea;
|
|
||||||
} else if (["filter", "column", "row", "data"].includes(overId)) {
|
|
||||||
targetArea = overId as PivotAreaType;
|
|
||||||
} else {
|
|
||||||
targetArea = overId.split("-")[0] as PivotAreaType;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 같은 영역 내 정렬
|
// 같은 영역 내 정렬
|
||||||
if (sourceArea === targetArea) {
|
if (sourceArea === targetArea) {
|
||||||
|
|
@ -441,7 +406,6 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
}
|
}
|
||||||
return f;
|
return f;
|
||||||
});
|
});
|
||||||
|
|
||||||
onFieldsChange(newFields);
|
onFieldsChange(newFields);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -479,42 +443,16 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
? fields.find((f) => `${f.area}-${f.field}` === activeId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 각 영역의 필드 수 계산
|
|
||||||
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
|
|
||||||
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
|
|
||||||
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
|
|
||||||
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
|
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
|
<div className="border-b border-border px-3 py-2">
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
{filterCount > 0 && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Filter className="h-3 w-3" />
|
|
||||||
필터 {filterCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Columns className="h-3 w-3" />
|
|
||||||
열 {columnCount}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Rows className="h-3 w-3" />
|
|
||||||
행 {rowCount}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<BarChart3 className="h-3 w-3" />
|
|
||||||
데이터 {dataCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="text-xs h-6 px-2"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
필드 설정
|
필드 패널 펼치기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -528,9 +466,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="border-b border-border bg-muted/20 p-2">
|
<div className="border-b border-border bg-muted/20 p-3">
|
||||||
{/* 4개 영역 배치: 2x2 그리드 */}
|
{/* 2x2 그리드로 영역 배치 */}
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{/* 필터 영역 */}
|
{/* 필터 영역 */}
|
||||||
<DroppableArea
|
<DroppableArea
|
||||||
area="filter"
|
area="filter"
|
||||||
|
|
@ -578,12 +516,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
|
||||||
|
|
||||||
{/* 접기 버튼 */}
|
{/* 접기 버튼 */}
|
||||||
{onToggleCollapse && (
|
{onToggleCollapse && (
|
||||||
<div className="flex justify-center mt-1.5">
|
<div className="flex justify-center mt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="text-xs h-5 px-2"
|
className="text-xs h-6"
|
||||||
>
|
>
|
||||||
필드 패널 접기
|
필드 패널 접기
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,4 @@ export { FieldChooser } from "./FieldChooser";
|
||||||
export { DrillDownModal } from "./DrillDownModal";
|
export { DrillDownModal } from "./DrillDownModal";
|
||||||
export { FilterPopup } from "./FilterPopup";
|
export { FilterPopup } from "./FilterPopup";
|
||||||
export { PivotChart } from "./PivotChart";
|
export { PivotChart } from "./PivotChart";
|
||||||
export { PivotContextMenu } from "./ContextMenu";
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,18 +51,14 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe
|
||||||
// 보이는 아이템 수
|
// 보이는 아이템 수
|
||||||
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
const visibleCount = Math.ceil(containerHeight / itemHeight);
|
||||||
|
|
||||||
// 시작/끝 인덱스 계산 (음수 방지)
|
// 시작/끝 인덱스 계산
|
||||||
const { startIndex, endIndex } = useMemo(() => {
|
const { startIndex, endIndex } = useMemo(() => {
|
||||||
// itemCount가 0이면 빈 배열
|
|
||||||
if (itemCount === 0) {
|
|
||||||
return { startIndex: 0, endIndex: -1 };
|
|
||||||
}
|
|
||||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||||
const end = Math.min(
|
const end = Math.min(
|
||||||
itemCount - 1,
|
itemCount - 1,
|
||||||
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
|
||||||
);
|
);
|
||||||
return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록
|
return { startIndex: start, endIndex: end };
|
||||||
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
}, [scrollTop, itemHeight, containerHeight, itemCount, overscan]);
|
||||||
|
|
||||||
// 전체 높이
|
// 전체 높이
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue