Compare commits
98 Commits
025c28bdbe
...
607d686535
| Author | SHA1 | Date |
|---|---|---|
|
|
607d686535 | |
|
|
b39c98c73f | |
|
|
b1fba586cb | |
|
|
752e16bd40 | |
|
|
1d068e0a20 | |
|
|
d429e237ee | |
|
|
8c0572e0ac | |
|
|
8344486e56 | |
|
|
67f00643bc | |
|
|
e3df70d0fa | |
|
|
623ade4f28 | |
|
|
ae4e21e1ac | |
|
|
e46d216aae | |
|
|
16cb1ea1af | |
|
|
ad8b1791bc | |
|
|
4781a17b71 | |
|
|
8cdb8a3047 | |
|
|
58d658e638 | |
|
|
a67b53038f | |
|
|
d3701cfe1e | |
|
|
95da69ec70 | |
|
|
f0cacb9401 | |
|
|
0ee36d9b35 | |
|
|
b6ed76f243 | |
|
|
7f186c509f | |
|
|
279ec17623 | |
|
|
d69d509893 | |
|
|
e785dbbe6e | |
|
|
d45443521d | |
|
|
338f3c27fd | |
|
|
901cb04a88 | |
|
|
0658ce41f9 | |
|
|
6ea3aef396 | |
|
|
b4bfb9964f | |
|
|
9d74baf60a | |
|
|
28f67cb0b6 | |
|
|
3ce7691b0c | |
|
|
7ea49cfc9e | |
|
|
ee1ea4190d | |
|
|
6c920b21a4 | |
|
|
f160ba2a1b | |
|
|
c71641c32c | |
|
|
ce85528ddf | |
|
|
7181822832 | |
|
|
57d86c8ef1 | |
|
|
e937ba9161 | |
|
|
e168753d87 | |
|
|
bed7f5f5c4 | |
|
|
321c52a1f8 | |
|
|
5c098a0395 | |
|
|
c3adb4216f | |
|
|
7920928706 | |
|
|
19dbe59e3a | |
|
|
71af4dfc6b | |
|
|
08ea14eed7 | |
|
|
45f0c667c2 | |
|
|
7f9885f84e | |
|
|
5e688ca28c | |
|
|
6295b52e87 | |
|
|
a34230ae90 | |
|
|
3fdc9e36f4 | |
|
|
a3c29b28ad | |
|
|
9dbb6b100a | |
|
|
b4a1fe6889 | |
|
|
722eebb00b | |
|
|
83597a7cc2 | |
|
|
42583a75eb | |
|
|
30012d908a | |
|
|
56036d7fe4 | |
|
|
e5faff2853 | |
|
|
291a389486 | |
|
|
d7eb91a043 | |
|
|
f7ab488b3c | |
|
|
2a8acd9a02 | |
|
|
0dafd417ef | |
|
|
42d75e1aaf | |
|
|
5daef415ad | |
|
|
5102eec46f | |
|
|
b68c0bd340 | |
|
|
cc4d294906 | |
|
|
5948799a29 | |
|
|
97675458d7 | |
|
|
2513b89ca2 | |
|
|
9af7fe5b98 | |
|
|
9c26738604 | |
|
|
01e47a1830 | |
|
|
7569394645 | |
|
|
584d6b183b | |
|
|
6e9cbccf47 | |
|
|
b01efd293c | |
|
|
ac526c8578 | |
|
|
a717f97b34 | |
|
|
d33daf0a3d | |
|
|
034ef59ef9 | |
|
|
9597494685 | |
|
|
979a5ddd9a | |
|
|
91d00aa784 | |
|
|
2487c79a61 |
File diff suppressed because it is too large
Load Diff
|
|
@ -1,559 +1,40 @@
|
|||
# 다국어 지원 컴포넌트 개발 가이드
|
||||
|
||||
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
|
||||
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
|
||||
|
||||
---
|
||||
description: (Deprecated) 이 파일은 component-development-guide.mdc로 통합되었습니다.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## 1. 타입 정의 시 다국어 필드 추가
|
||||
# 다국어 지원 컴포넌트 개발 가이드 (Deprecated)
|
||||
|
||||
### 기본 원칙
|
||||
> **이 문서는 더 이상 사용되지 않습니다.**
|
||||
>
|
||||
> 새로운 통합 가이드를 참조하세요: `component-development-guide.mdc`
|
||||
|
||||
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
|
||||
다국어 지원을 포함한 모든 컴포넌트 개발 가이드가 다음 파일로 통합되었습니다:
|
||||
|
||||
### 단일 텍스트 속성
|
||||
**[component-development-guide.mdc](.cursor/rules/component-development-guide.mdc)**
|
||||
|
||||
```typescript
|
||||
interface MyComponentConfig {
|
||||
// 기본 텍스트
|
||||
title?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
통합된 가이드에는 다음 내용이 포함되어 있습니다:
|
||||
|
||||
// 라벨
|
||||
label?: string;
|
||||
labelLangKeyId?: number;
|
||||
labelLangKey?: string;
|
||||
1. **엔티티 조인 컬럼 활용 (필수)**
|
||||
|
||||
// 플레이스홀더
|
||||
placeholder?: string;
|
||||
placeholderLangKeyId?: number;
|
||||
placeholderLangKey?: string;
|
||||
}
|
||||
```
|
||||
- 화면을 새로 만들어 임베딩하는 방식 대신 엔티티 관계 활용
|
||||
- `entityJoinApi.getEntityJoinColumns()` 사용법
|
||||
- 설정 패널에서 조인 컬럼 표시 패턴
|
||||
|
||||
### 배열/목록 속성 (컬럼, 탭 등)
|
||||
2. **폼 데이터 관리**
|
||||
|
||||
```typescript
|
||||
interface ColumnConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 기타 속성
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
- `useFormCompatibility` 훅 사용법
|
||||
- 레거시 `beforeFormSave` 이벤트 호환성
|
||||
|
||||
interface TabConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 탭 제목도 별도로
|
||||
title?: string;
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
}
|
||||
3. **다국어 지원**
|
||||
|
||||
interface MyComponentConfig {
|
||||
columns?: ColumnConfig[];
|
||||
tabs?: TabConfig[];
|
||||
}
|
||||
```
|
||||
- 타입 정의 시 `langKeyId`, `langKey` 필드 추가
|
||||
- 라벨 추출/매핑 로직
|
||||
- 번역 표시 로직
|
||||
|
||||
### 버튼 컴포넌트
|
||||
4. **컬럼 설정 패널 구현**
|
||||
|
||||
```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. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||
5. **체크리스트**
|
||||
- 새 컴포넌트 개발 시 확인 항목
|
||||
|
|
|
|||
|
|
@ -1044,7 +1044,6 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2372,7 +2371,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3476,7 +3474,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3713,7 +3710,6 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3931,7 +3927,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4458,7 +4453,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -5669,7 +5663,6 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -7432,7 +7425,6 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8402,6 +8394,7 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -9290,7 +9283,6 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -10141,6 +10133,7 @@
|
|||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10949,7 +10942,6 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11055,7 +11047,6 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
|||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
|
|
@ -83,6 +83,7 @@ import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조
|
|||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -253,6 +254,7 @@ app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값
|
|||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/entity", entityOptionsRouter); // 엔티티 옵션 (UnifiedSelect용)
|
||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||
|
|
@ -261,6 +263,7 @@ app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연
|
|||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -759,3 +759,45 @@ export async function getAllRelationships(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 테이블 간의 조인 관계 조회 (마스터-디테일 저장용)
|
||||
*/
|
||||
export async function getJoinRelationship(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { mainTable, detailTable } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!mainTable || !detailTable) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "메인 테이블과 디테일 테이블이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// DataflowService에서 조인 관계 조회
|
||||
const { DataflowService } = await import("../services/dataflowService");
|
||||
const dataflowService = new DataflowService();
|
||||
|
||||
const result = await dataflowService.getJoinRelationshipBetweenTables(
|
||||
mainTable,
|
||||
detailTable,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("조인 관계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "조인 관계 조회 실패",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* 카테고리 트리 컨트롤러 (테스트용)
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 인증된 사용자 타입
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* GET /api/category-tree/test/all-category-keys
|
||||
* 주의: 이 라우트는 /test/:tableName/:columnName 보다 먼저 정의되어야 함
|
||||
*/
|
||||
router.get("/test/all-category-keys", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const keys = await categoryTreeService.getAllCategoryKeys(companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: keys,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("전체 카테고리 키 목록 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 트리 조회
|
||||
* GET /api/category-tree/test/:tableName/:columnName
|
||||
*/
|
||||
router.get("/test/:tableName/:columnName", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const tree = await categoryTreeService.getCategoryTree(companyCode, tableName, columnName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tree,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 트리 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회 (플랫 리스트)
|
||||
* GET /api/category-tree/test/:tableName/:columnName/flat
|
||||
*/
|
||||
router.get("/test/:tableName/:columnName/flat", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const list = await categoryTreeService.getCategoryList(companyCode, tableName, columnName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: list,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 목록 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 단일 조회
|
||||
* GET /api/category-tree/test/value/:valueId
|
||||
*/
|
||||
router.get("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const value = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
* POST /api/category-tree/test/value
|
||||
*/
|
||||
router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const input: CreateCategoryValueInput = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const createdBy = req.user?.userId;
|
||||
|
||||
if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName, columnName, valueCode, valueLabel은 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 생성 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
* PUT /api/category-tree/test/value/:valueId
|
||||
*/
|
||||
router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const input: UpdateCategoryValueInput = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 수정 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제
|
||||
* DELETE /api/category-tree/test/value/:valueId
|
||||
*/
|
||||
router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
* GET /api/category-tree/test/columns/:tableName
|
||||
*/
|
||||
router.get("/test/columns/:tableName", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const columns = await categoryTreeService.getCategoryColumns(companyCode, tableName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: columns,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 컬럼 목록 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -412,7 +412,13 @@ export class EntityJoinController {
|
|||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
||||
|
||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
const joinConfigs = allJoinConfigs.filter(
|
||||
(config) => config.referenceTable !== "table_column_category_values"
|
||||
);
|
||||
|
||||
if (joinConfigs.length === 0) {
|
||||
res.status(200).json({
|
||||
|
|
@ -449,6 +455,7 @@ export class EntityJoinController {
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnName,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType || "text",
|
||||
isNullable: true, // 기본값으로 설정
|
||||
maxLength: undefined, // 정보가 없으므로 undefined
|
||||
description: col.displayName,
|
||||
|
|
@ -477,6 +484,7 @@ export class EntityJoinController {
|
|||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}> = [];
|
||||
|
|
@ -491,6 +499,7 @@ export class EntityJoinController {
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType || "text",
|
||||
joinAlias,
|
||||
suggestedLabel,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,101 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엔티티 옵션 조회 API (UnifiedSelect용)
|
||||
* GET /api/entity/:tableName/options
|
||||
*
|
||||
* Query Params:
|
||||
* - value: 값 컬럼 (기본: id)
|
||||
* - label: 표시 컬럼 (기본: name)
|
||||
*/
|
||||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { value = "id", label = "name" } = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
logger.warn("엔티티 옵션 조회 실패: 테이블명이 없음", { tableName });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 지정되지 않았습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
// 테이블의 실제 컬럼 목록 조회
|
||||
const columnsResult = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 요청된 컬럼 검증
|
||||
const valueColumn = existingColumns.has(value as string) ? value : "id";
|
||||
const labelColumn = existingColumns.has(label as string) ? label : "name";
|
||||
|
||||
// 둘 다 없으면 에러
|
||||
if (!existingColumns.has(valueColumn as string)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `테이블 "${tableName}"에 값 컬럼 "${value}"이 존재하지 않습니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
// label 컬럼이 없으면 value 컬럼을 label로도 사용
|
||||
const effectiveLabelColumn = existingColumns.has(labelColumn as string) ? labelColumn : valueColumn;
|
||||
|
||||
// WHERE 조건 (멀티테넌시)
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode !== "*" && existingColumns.has("company_code")) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행 (최대 500개)
|
||||
const query = `
|
||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
|
||||
FROM ${tableName}
|
||||
${whereClause}
|
||||
ORDER BY ${effectiveLabelColumn} ASC
|
||||
LIMIT 500
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("엔티티 옵션 조회 성공", {
|
||||
tableName,
|
||||
valueColumn,
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 옵션 조회 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
|
|
|
|||
|
|
@ -169,14 +169,22 @@ router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res:
|
|||
const { ruleId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates });
|
||||
|
||||
try {
|
||||
const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode);
|
||||
logger.info("채번 규칙 수정 성공", { ruleId, companyCode });
|
||||
return res.json({ success: true, data: updatedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message.includes("찾을 수 없거나")) {
|
||||
return res.status(404).json({ success: false, error: error.message });
|
||||
}
|
||||
logger.error("규칙 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
|
@ -202,9 +210,10 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r
|
|||
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 미리보기 실패", { error: error.message });
|
||||
|
|
@ -216,11 +225,12 @@ router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequ
|
|||
router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body; // 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode });
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode, hasFormData: !!formData });
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
|
|
@ -257,4 +267,108 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques
|
|||
}
|
||||
});
|
||||
|
||||
// ==================== 테스트 테이블용 API ====================
|
||||
|
||||
// [테스트] 테이블+컬럼 기반 채번 규칙 조회
|
||||
router.get("/test/by-column/:tableName/:columnName", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, columnName);
|
||||
return res.json({ success: true, data: rule });
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// [테스트] 테스트 테이블에 채번 규칙 저장
|
||||
router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const ruleConfig = req.body;
|
||||
|
||||
logger.info("[테스트] 채번 규칙 저장 요청", {
|
||||
ruleId: ruleConfig.ruleId,
|
||||
tableName: ruleConfig.tableName,
|
||||
columnName: ruleConfig.columnName,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!ruleConfig.tableName || !ruleConfig.columnName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName and columnName are required"
|
||||
});
|
||||
}
|
||||
|
||||
const savedRule = await numberingRuleService.saveRuleToTest(ruleConfig, companyCode, userId);
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// [테스트] 테스트 테이블에서 채번 규칙 삭제
|
||||
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
return res.json({ success: true, message: "테스트 채번 규칙이 삭제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// [테스트] 코드 미리보기 (테스트 테이블 사용)
|
||||
router.post("/test/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
const { formData } = req.body;
|
||||
|
||||
try {
|
||||
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
|
||||
return res.json({ success: true, data: { generatedCode: previewCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 코드 미리보기 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 회사별 채번규칙 복제 API ====================
|
||||
|
||||
// 회사별 채번규칙 복제
|
||||
router.post("/copy-for-company", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
|
||||
// 최고 관리자만 사용 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: "최고 관리자만 사용할 수 있습니다"
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "sourceCompanyCode와 targetCompanyCode가 필요합니다"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await numberingRuleService.copyRulesForCompany(sourceCompanyCode, targetCompanyCode);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("회사별 채번규칙 복제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -369,14 +369,19 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
// 그룹에 화면 추가
|
||||
export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_id, screen_id, screen_role, display_order, is_default } = req.body;
|
||||
const { group_id, screen_id, screen_role, display_order, is_default, target_company_code } = req.body;
|
||||
|
||||
if (!group_id || !screen_id) {
|
||||
return res.status(400).json({ success: false, message: "그룹 ID와 화면 ID는 필수입니다." });
|
||||
}
|
||||
|
||||
// 최고 관리자가 다른 회사로 복제할 때 target_company_code 사용
|
||||
const effectiveCompanyCode = (userCompanyCode === "*" && target_company_code)
|
||||
? target_company_code
|
||||
: userCompanyCode;
|
||||
|
||||
const query = `
|
||||
INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
|
|
@ -388,13 +393,13 @@ export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response)
|
|||
screen_role || 'main',
|
||||
display_order || 0,
|
||||
is_default || 'N',
|
||||
companyCode === "*" ? "*" : companyCode,
|
||||
effectiveCompanyCode,
|
||||
userId
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("화면-그룹 연결 추가", { companyCode, groupId: group_id, screenId: screen_id });
|
||||
logger.info("화면-그룹 연결 추가", { companyCode: effectiveCompanyCode, groupId: group_id, screenId: screen_id });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "화면이 그룹에 추가되었습니다." });
|
||||
} catch (error: any) {
|
||||
|
|
@ -2251,3 +2256,168 @@ export const syncAllCompaniesController = async (req: AuthenticatedRequest, res:
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [PoC] screen_groups 기반 메뉴 트리 조회
|
||||
*
|
||||
* 기존 menu_info 대신 screen_groups를 사이드바 메뉴로 사용하기 위한 테스트 API
|
||||
* - screen_groups를 트리 구조로 반환
|
||||
* - 각 그룹에 연결된 기본 화면의 URL 포함
|
||||
* - menu_objid를 통해 권한 체크 가능
|
||||
*
|
||||
* DB 변경 없이 로직만 추가
|
||||
*/
|
||||
export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const { targetCompanyCode } = req.query;
|
||||
|
||||
// 조회할 회사 코드 결정
|
||||
const companyCode = userCompanyCode === "*" && targetCompanyCode
|
||||
? String(targetCompanyCode)
|
||||
: userCompanyCode;
|
||||
|
||||
logger.info("[PoC] screen_groups 기반 메뉴 트리 조회", {
|
||||
userCompanyCode,
|
||||
targetCompanyCode: companyCode
|
||||
});
|
||||
|
||||
// 1. screen_groups 조회 (계층 구조 포함)
|
||||
const groupsQuery = `
|
||||
SELECT
|
||||
sg.id,
|
||||
sg.group_name,
|
||||
sg.group_code,
|
||||
sg.parent_group_id,
|
||||
sg.group_level,
|
||||
sg.display_order,
|
||||
sg.icon,
|
||||
sg.is_active,
|
||||
sg.menu_objid,
|
||||
sg.company_code,
|
||||
-- 기본 화면 정보 (URL 생성용)
|
||||
(
|
||||
SELECT json_build_object(
|
||||
'screen_id', sd.screen_id,
|
||||
'screen_name', sd.screen_name,
|
||||
'screen_code', sd.screen_code
|
||||
)
|
||||
FROM screen_group_screens sgs
|
||||
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
|
||||
ORDER BY
|
||||
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
|
||||
sgs.display_order ASC
|
||||
LIMIT 1
|
||||
) as default_screen,
|
||||
-- 하위 화면 개수
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM screen_group_screens sgs
|
||||
WHERE sgs.group_id = sg.id AND sgs.company_code = sg.company_code
|
||||
) as screen_count
|
||||
FROM screen_groups sg
|
||||
WHERE sg.company_code = $1
|
||||
AND (sg.is_active = 'Y' OR sg.is_active IS NULL)
|
||||
ORDER BY sg.group_level ASC, sg.display_order ASC, sg.group_name ASC
|
||||
`;
|
||||
|
||||
const groupsResult = await pool.query(groupsQuery, [companyCode]);
|
||||
|
||||
// 2. 트리 구조로 변환
|
||||
const groups = groupsResult.rows;
|
||||
const groupMap = new Map<number, any>();
|
||||
const rootGroups: any[] = [];
|
||||
|
||||
// 먼저 모든 그룹을 Map에 저장
|
||||
for (const group of groups) {
|
||||
const menuItem = {
|
||||
id: group.id,
|
||||
objid: group.menu_objid || group.id, // 권한 체크용 (menu_objid 우선)
|
||||
name: group.group_name,
|
||||
name_kor: group.group_name,
|
||||
icon: group.icon,
|
||||
url: group.default_screen
|
||||
? `/screens/${group.default_screen.screen_id}`
|
||||
: null,
|
||||
screen_id: group.default_screen?.screen_id || null,
|
||||
screen_code: group.default_screen?.screen_code || null,
|
||||
screen_count: parseInt(group.screen_count) || 0,
|
||||
parent_id: group.parent_group_id,
|
||||
level: group.group_level || 0,
|
||||
display_order: group.display_order || 0,
|
||||
is_active: group.is_active === 'Y',
|
||||
menu_objid: group.menu_objid, // 기존 권한 시스템 연결용
|
||||
children: [],
|
||||
// menu_info 호환 필드
|
||||
menu_name_kor: group.group_name,
|
||||
menu_url: group.default_screen
|
||||
? `/screens/${group.default_screen.screen_id}`
|
||||
: null,
|
||||
parent_obj_id: null, // 나중에 설정
|
||||
seq: group.display_order || 0,
|
||||
status: group.is_active === 'Y' ? 'active' : 'inactive',
|
||||
};
|
||||
|
||||
groupMap.set(group.id, menuItem);
|
||||
}
|
||||
|
||||
// 부모-자식 관계 설정
|
||||
for (const group of groups) {
|
||||
const menuItem = groupMap.get(group.id);
|
||||
|
||||
if (group.parent_group_id && groupMap.has(group.parent_group_id)) {
|
||||
const parent = groupMap.get(group.parent_group_id);
|
||||
parent.children.push(menuItem);
|
||||
menuItem.parent_obj_id = parent.objid;
|
||||
} else {
|
||||
// 최상위 그룹
|
||||
rootGroups.push(menuItem);
|
||||
menuItem.parent_obj_id = "0";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 통계 정보
|
||||
const stats = {
|
||||
totalGroups: groups.length,
|
||||
groupsWithScreens: groups.filter(g => g.default_screen).length,
|
||||
groupsWithMenuObjid: groups.filter(g => g.menu_objid).length,
|
||||
rootGroups: rootGroups.length,
|
||||
};
|
||||
|
||||
logger.info("[PoC] screen_groups 메뉴 트리 생성 완료", stats);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "[PoC] screen_groups 기반 메뉴 트리",
|
||||
data: rootGroups,
|
||||
stats,
|
||||
// 플랫 리스트도 제공 (기존 menu_info 형식 호환)
|
||||
flatList: Array.from(groupMap.values()).map(item => ({
|
||||
objid: String(item.objid),
|
||||
OBJID: String(item.objid),
|
||||
menu_name_kor: item.name,
|
||||
MENU_NAME_KOR: item.name,
|
||||
menu_url: item.url,
|
||||
MENU_URL: item.url,
|
||||
parent_obj_id: String(item.parent_obj_id || "0"),
|
||||
PARENT_OBJ_ID: String(item.parent_obj_id || "0"),
|
||||
seq: item.seq,
|
||||
SEQ: item.seq,
|
||||
status: item.status,
|
||||
STATUS: item.status,
|
||||
menu_type: 1, // 사용자 메뉴
|
||||
MENU_TYPE: 1,
|
||||
screen_group_id: item.id,
|
||||
menu_objid: item.menu_objid,
|
||||
})),
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("[PoC] screen_groups 메뉴 트리 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "메뉴 트리 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -834,3 +834,264 @@ export const cleanupDeletedScreenMenuAssignments = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
export const updateTabScreenReferences = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { targetScreenIds, screenIdMap } = req.body;
|
||||
|
||||
if (!targetScreenIds || !Array.isArray(targetScreenIds)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "targetScreenIds 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!screenIdMap || typeof screenIdMap !== "object") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenIdMap 객체가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.updateTabScreenReferences(
|
||||
targetScreenIds,
|
||||
screenIdMap
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${result.updated}개 레이아웃의 탭 참조가 업데이트되었습니다.`,
|
||||
updated: result.updated,
|
||||
details: result.details,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("탭 screenId 참조 업데이트 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "탭 screenId 참조 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
|
||||
export const copyScreenMenuAssignments = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode, screenIdMap } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
// 권한 체크: 최고 관리자만 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!screenIdMap || typeof screenIdMap !== "object") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenIdMap 객체가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyScreenMenuAssignments(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
screenIdMap
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `화면-메뉴 할당 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("화면-메뉴 할당 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면-메뉴 할당 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 코드 카테고리 + 코드 복제
|
||||
export const copyCodeCategoryAndCodes = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCodeCategoryAndCodes(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `코드 카테고리 ${result.copiedCategories}개, 코드 ${result.copiedCodes}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리/코드 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 카테고리/코드 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 + 값 복제
|
||||
export const copyCategoryMapping = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCategoryMapping(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `카테고리 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("카테고리 매핑/값 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 매핑/값 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 타입관리 입력타입 설정 복제
|
||||
export const copyTableTypeColumns = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyTableTypeColumns(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `테이블 타입 컬럼 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("테이블 타입 컬럼 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 타입 컬럼 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 연쇄관계 설정 복제
|
||||
export const copyCascadingRelation = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { sourceCompanyCode, targetCompanyCode } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
if (userCompanyCode !== "*") {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: "최고 관리자만 이 기능을 사용할 수 있습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceCompanyCode || !targetCompanyCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "sourceCompanyCode와 targetCompanyCode가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await screenManagementService.copyCascadingRelation(
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `연쇄관계 설정 ${result.copiedCount}개 복제 완료`,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("연쇄관계 설정 복제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "연쇄관계 설정 복제에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,11 +97,16 @@ export async function getColumnList(
|
|||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
|
||||
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
|
||||
const bustCache = !!req.query._t;
|
||||
|
||||
const result = await tableManagementService.getColumnList(
|
||||
tableName,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string),
|
||||
companyCode // 🔥 회사 코드 전달
|
||||
companyCode, // 🔥 회사 코드 전달
|
||||
bustCache // 🔥 캐시 버스팅 옵션
|
||||
);
|
||||
|
||||
logger.info(
|
||||
|
|
@ -2280,3 +2285,90 @@ export async function getTableEntityRelations(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 테이블을 참조(FK로 연결)하는 테이블 목록 조회
|
||||
* GET /api/table-management/columns/:tableName/referenced-by
|
||||
*
|
||||
* column_labels에서 reference_table이 현재 테이블인 레코드를 찾아서
|
||||
* 해당 테이블과 FK 컬럼 정보를 반환합니다.
|
||||
*/
|
||||
export async function getReferencedByTables(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
logger.info(
|
||||
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
|
||||
);
|
||||
|
||||
if (!tableName) {
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "tableName 파라미터가 필요합니다.",
|
||||
error: {
|
||||
code: "MISSING_PARAMETERS",
|
||||
details: "tableName 경로 파라미터가 필요합니다.",
|
||||
},
|
||||
};
|
||||
res.status(400).json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// column_labels에서 reference_table이 현재 테이블인 레코드 조회
|
||||
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
|
||||
const sqlQuery = `
|
||||
SELECT DISTINCT
|
||||
cl.table_name,
|
||||
cl.column_name,
|
||||
cl.column_label,
|
||||
cl.reference_table,
|
||||
cl.reference_column,
|
||||
cl.display_column,
|
||||
cl.table_name as table_label
|
||||
FROM column_labels cl
|
||||
WHERE cl.reference_table = $1
|
||||
AND cl.input_type = 'entity'
|
||||
ORDER BY cl.table_name, cl.column_name
|
||||
`;
|
||||
|
||||
const result = await query(sqlQuery, [tableName]);
|
||||
|
||||
const referencedByTables = result.map((row: any) => ({
|
||||
tableName: row.table_name,
|
||||
tableLabel: row.table_label,
|
||||
columnName: row.column_name,
|
||||
columnLabel: row.column_label,
|
||||
referenceTable: row.reference_table,
|
||||
referenceColumn: row.reference_column || "id",
|
||||
displayColumn: row.display_column,
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
|
||||
);
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`,
|
||||
data: referencedByTables,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("테이블 참조 관계 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "테이블 참조 관계 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "REFERENCED_BY_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
executeOptimizedButton,
|
||||
executeSimpleDataflow,
|
||||
getJobStatus,
|
||||
getJoinRelationship,
|
||||
} from "../controllers/buttonDataflowController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -61,6 +62,13 @@ router.post("/execute-simple", executeSimpleDataflow);
|
|||
// 백그라운드 작업 상태 조회
|
||||
router.get("/job-status/:jobId", getJobStatus);
|
||||
|
||||
// ============================================================================
|
||||
// 🔥 테이블 관계 조회 (마스터-디테일 저장용)
|
||||
// ============================================================================
|
||||
|
||||
// 두 테이블 간의 조인 관계 조회
|
||||
router.get("/join-relationship/:mainTable/:detailTable", getJoinRelationship);
|
||||
|
||||
// ============================================================================
|
||||
// 🔥 레거시 호환성 (기존 API와 호환)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -57,3 +57,6 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,3 +53,6 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,3 +69,6 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,3 +57,6 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* 카테고리 트리 라우트 (테스트용)
|
||||
*/
|
||||
|
||||
import categoryTreeController from "../controllers/categoryTreeController";
|
||||
|
||||
export default categoryTreeController;
|
||||
|
||||
|
|
@ -73,4 +73,20 @@ router.get("/categories/:categoryCode/options", (req, res) =>
|
|||
commonCodeController.getCodeOptions(req, res)
|
||||
);
|
||||
|
||||
// 계층 구조 코드 조회 (트리 형태)
|
||||
router.get("/categories/:categoryCode/hierarchy", (req, res) =>
|
||||
commonCodeController.getCodesHierarchy(req, res)
|
||||
);
|
||||
|
||||
// 자식 코드 조회 (연쇄 선택용)
|
||||
router.get("/categories/:categoryCode/children", (req, res) =>
|
||||
commonCodeController.getChildCodes(req, res)
|
||||
);
|
||||
|
||||
// 카테고리 → 공통코드 호환 API (레거시 지원)
|
||||
// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작
|
||||
router.get("/category-options/:tableName/:columnName", (req, res) =>
|
||||
commonCodeController.getCategoryOptionsAsCode(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { searchEntity } from "../controllers/entitySearchController";
|
||||
import { searchEntity, getEntityOptions } from "../controllers/entitySearchController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -12,3 +12,12 @@ router.get("/:tableName", authenticateToken, searchEntity);
|
|||
|
||||
export default router;
|
||||
|
||||
// 엔티티 옵션 라우터 (UnifiedSelect용)
|
||||
export const entityOptionsRouter = Router();
|
||||
|
||||
/**
|
||||
* 엔티티 옵션 조회 API
|
||||
* GET /api/entity/:tableName/options
|
||||
*/
|
||||
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ import {
|
|||
getScreensByMenu,
|
||||
unassignScreenFromMenu,
|
||||
cleanupDeletedScreenMenuAssignments,
|
||||
updateTabScreenReferences,
|
||||
copyScreenMenuAssignments,
|
||||
copyCodeCategoryAndCodes,
|
||||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -83,4 +89,22 @@ router.post(
|
|||
cleanupDeletedScreenMenuAssignments
|
||||
);
|
||||
|
||||
// 그룹 복제 완료 후 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
router.post("/screens/update-tab-references", updateTabScreenReferences);
|
||||
|
||||
// 화면-메뉴 할당 복제 (다른 회사로 복제 시)
|
||||
router.post("/copy-menu-assignments", copyScreenMenuAssignments);
|
||||
|
||||
// 코드 카테고리 + 코드 복제
|
||||
router.post("/copy-code-category", copyCodeCategoryAndCodes);
|
||||
|
||||
// 카테고리 매핑 + 값 복제
|
||||
router.post("/copy-category-mapping", copyCategoryMapping);
|
||||
|
||||
// 테이블 타입 컬럼 복제
|
||||
router.post("/copy-table-type-columns", copyTableTypeColumns);
|
||||
|
||||
// 연쇄관계 설정 복제
|
||||
router.post("/copy-cascading-relation", copyCascadingRelation);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -54,6 +55,14 @@ router.get("/tables/entity-relations", getTableEntityRelations);
|
|||
*/
|
||||
router.get("/tables/:tableName/columns", getColumnList);
|
||||
|
||||
/**
|
||||
* 현재 테이블을 참조하는 테이블 목록 조회
|
||||
* GET /api/table-management/columns/:tableName/referenced-by
|
||||
*
|
||||
* 리피터 컴포넌트에서 저장 테이블 선택 시 FK 관계를 자동으로 가져오기 위해 사용
|
||||
*/
|
||||
router.get("/columns/:tableName/referenced-by", getReferencedByTables);
|
||||
|
||||
/**
|
||||
* 테이블 라벨 설정
|
||||
* PUT /api/table-management/tables/:tableName/label
|
||||
|
|
|
|||
|
|
@ -0,0 +1,546 @@
|
|||
/**
|
||||
* 카테고리 트리 서비스 (테스트용)
|
||||
* - 트리 구조 지원 (최대 3단계: 대분류/중분류/소분류)
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 카테고리 값 타입
|
||||
export interface CategoryValue {
|
||||
valueId: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder: number;
|
||||
parentValueId: number | null;
|
||||
depth: number;
|
||||
path: string | null;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
companyCode: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
children?: CategoryValue[];
|
||||
}
|
||||
|
||||
// 카테고리 값 생성 입력
|
||||
export interface CreateCategoryValueInput {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// 카테고리 값 수정 입력
|
||||
export interface UpdateCategoryValueInput {
|
||||
valueCode?: string;
|
||||
valueLabel?: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
class CategoryTreeService {
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (트리 구조로 반환)
|
||||
*/
|
||||
async getCategoryTree(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("카테고리 트리 조회 시작", { companyCode, tableName, columnName });
|
||||
|
||||
const query = `
|
||||
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,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
||||
const flatList = result.rows as CategoryValue[];
|
||||
|
||||
const tree = this.buildTree(flatList);
|
||||
|
||||
logger.info("카테고리 트리 조회 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
totalCount: flatList.length,
|
||||
rootCount: tree.length,
|
||||
});
|
||||
|
||||
return tree;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 트리 조회 실패", { error: err.message, tableName, columnName });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (플랫 리스트)
|
||||
*/
|
||||
async getCategoryList(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
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,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
||||
return result.rows as CategoryValue[];
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 목록 조회 실패", { error: err.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 단일 조회
|
||||
*/
|
||||
async getCategoryValue(companyCode: string, valueId: number): Promise<CategoryValue | null> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
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,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, valueId]);
|
||||
return result.rows[0] || null;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 조회 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
async createCategoryValue(companyCode: string, input: CreateCategoryValueInput, createdBy?: string): Promise<CategoryValue> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// depth 계산
|
||||
let depth = 1;
|
||||
let path = input.valueLabel;
|
||||
|
||||
if (input.parentValueId) {
|
||||
const parent = await this.getCategoryValue(companyCode, input.parentValueId);
|
||||
if (parent) {
|
||||
depth = parent.depth + 1;
|
||||
path = parent.path ? `${parent.path}/${input.valueLabel}` : input.valueLabel;
|
||||
|
||||
if (depth > 3) {
|
||||
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values_test (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
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,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
`;
|
||||
|
||||
const params = [
|
||||
input.tableName,
|
||||
input.columnName,
|
||||
input.valueCode,
|
||||
input.valueLabel,
|
||||
input.valueOrder ?? 0,
|
||||
input.parentValueId ?? null,
|
||||
depth,
|
||||
path,
|
||||
input.description ?? null,
|
||||
input.color ?? null,
|
||||
input.icon ?? null,
|
||||
input.isActive ?? true,
|
||||
input.isDefault ?? false,
|
||||
companyCode,
|
||||
createdBy ?? null,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("카테고리 값 생성 완료", {
|
||||
valueId: result.rows[0].valueId,
|
||||
valueLabel: input.valueLabel,
|
||||
depth,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 생성 실패", { error: err.message, input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
async updateCategoryValue(
|
||||
companyCode: string,
|
||||
valueId: number,
|
||||
input: UpdateCategoryValueInput,
|
||||
updatedBy?: string
|
||||
): Promise<CategoryValue | null> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const current = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newPath = current.path;
|
||||
let newDepth = current.depth;
|
||||
|
||||
if (input.valueLabel && input.valueLabel !== current.valueLabel) {
|
||||
if (current.parentValueId) {
|
||||
const parent = await this.getCategoryValue(companyCode, current.parentValueId);
|
||||
if (parent && parent.path) {
|
||||
newPath = `${parent.path}/${input.valueLabel}`;
|
||||
} else {
|
||||
newPath = input.valueLabel;
|
||||
}
|
||||
} else {
|
||||
newPath = input.valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.parentValueId !== undefined && input.parentValueId !== current.parentValueId) {
|
||||
if (input.parentValueId) {
|
||||
const newParent = await this.getCategoryValue(companyCode, input.parentValueId);
|
||||
if (newParent) {
|
||||
newDepth = newParent.depth + 1;
|
||||
const label = input.valueLabel ?? current.valueLabel;
|
||||
newPath = newParent.path ? `${newParent.path}/${label}` : label;
|
||||
|
||||
if (newDepth > 3) {
|
||||
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newDepth = 1;
|
||||
newPath = input.valueLabel ?? current.valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE category_values_test
|
||||
SET
|
||||
value_code = COALESCE($3, value_code),
|
||||
value_label = COALESCE($4, value_label),
|
||||
value_order = COALESCE($5, value_order),
|
||||
parent_value_id = $6,
|
||||
depth = $7,
|
||||
path = $8,
|
||||
description = COALESCE($9, description),
|
||||
color = COALESCE($10, color),
|
||||
icon = COALESCE($11, icon),
|
||||
is_active = COALESCE($12, is_active),
|
||||
is_default = COALESCE($13, is_default),
|
||||
updated_at = NOW(),
|
||||
updated_by = $14
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
RETURNING
|
||||
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,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
`;
|
||||
|
||||
const params = [
|
||||
companyCode,
|
||||
valueId,
|
||||
input.valueCode ?? null,
|
||||
input.valueLabel ?? null,
|
||||
input.valueOrder ?? null,
|
||||
input.parentValueId !== undefined ? input.parentValueId : current.parentValueId,
|
||||
newDepth,
|
||||
newPath,
|
||||
input.description ?? null,
|
||||
input.color ?? null,
|
||||
input.icon ?? null,
|
||||
input.isActive ?? null,
|
||||
input.isDefault ?? null,
|
||||
updatedBy ?? null,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (input.valueLabel || input.parentValueId !== undefined) {
|
||||
await this.updateChildrenPaths(companyCode, valueId, newPath || "");
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 수정 완료", { valueId });
|
||||
|
||||
return result.rows[0] || null;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 수정 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
DELETE FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
RETURNING value_id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, valueId]);
|
||||
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
logger.info("카테고리 값 삭제 완료", { valueId });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 항목들의 path 업데이트
|
||||
*/
|
||||
private async updateChildrenPaths(companyCode: string, parentValueId: number, parentPath: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
SELECT value_id, value_label
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, parentValueId]);
|
||||
|
||||
for (const child of result.rows) {
|
||||
const newPath = `${parentPath}/${child.value_label}`;
|
||||
|
||||
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
newPath,
|
||||
child.value_id,
|
||||
]);
|
||||
|
||||
await this.updateChildrenPaths(companyCode, child.value_id, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플랫 리스트를 트리 구조로 변환
|
||||
*/
|
||||
private buildTree(flatList: CategoryValue[]): CategoryValue[] {
|
||||
const map = new Map<number, CategoryValue>();
|
||||
const roots: CategoryValue[] = [];
|
||||
|
||||
for (const item of flatList) {
|
||||
map.set(item.valueId, { ...item, children: [] });
|
||||
}
|
||||
|
||||
for (const item of flatList) {
|
||||
const node = map.get(item.valueId)!;
|
||||
|
||||
if (item.parentValueId && map.has(item.parentValueId)) {
|
||||
const parent = map.get(item.parentValueId)!;
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
async getCategoryColumns(companyCode: string, tableName: string): Promise<{ columnName: string; columnLabel: string }[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT column_name AS "columnName", column_label AS "columnLabel"
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'category'
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY column_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [tableName, companyCode]);
|
||||
return result.rows;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 컬럼 목록 조회 실패", { error: err.message, tableName });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
|
||||
* category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회
|
||||
* 라벨 정보도 함께 반환
|
||||
*/
|
||||
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
|
||||
logger.info("getAllCategoryKeys 호출", { companyCode });
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT
|
||||
cv.table_name AS "tableName",
|
||||
cv.column_name AS "columnName",
|
||||
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
|
||||
COALESCE(cl.column_label, cv.column_name) AS "columnLabel"
|
||||
FROM category_values_test cv
|
||||
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
|
||||
LEFT JOIN column_labels cl ON cl.table_name = cv.table_name AND cl.column_name = cv.column_name
|
||||
WHERE cv.company_code = $1 OR cv.company_code = '*'
|
||||
ORDER BY cv.table_name, cv.column_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode]);
|
||||
logger.info("전체 카테고리 키 목록 조회 완료", { count: result.rows.length });
|
||||
return result.rows;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("전체 카테고리 키 목록 조회 실패", { error: err.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const categoryTreeService = new CategoryTreeService();
|
||||
|
||||
|
|
@ -337,6 +337,110 @@ export class DataflowService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 테이블 간의 조인 관계 조회 (마스터-디테일 저장용)
|
||||
* @param mainTable 메인 테이블명 (마스터)
|
||||
* @param detailTable 디테일 테이블명 (리피터)
|
||||
* @param companyCode 회사코드
|
||||
* @returns 조인 컬럼 매핑 정보
|
||||
*/
|
||||
async getJoinRelationshipBetweenTables(
|
||||
mainTable: string,
|
||||
detailTable: string,
|
||||
companyCode: string
|
||||
): Promise<{
|
||||
found: boolean;
|
||||
mainColumn?: string;
|
||||
detailColumn?: string;
|
||||
relationshipType?: string;
|
||||
}> {
|
||||
try {
|
||||
logger.info(
|
||||
`DataflowService: 테이블 간 조인 관계 조회 - 메인: ${mainTable}, 디테일: ${detailTable}`
|
||||
);
|
||||
|
||||
// 양방향 조회 (from → to 또는 to → from)
|
||||
let queryText = `
|
||||
SELECT
|
||||
from_table_name,
|
||||
from_column_name,
|
||||
to_table_name,
|
||||
to_column_name,
|
||||
relationship_type,
|
||||
settings
|
||||
FROM table_relationships
|
||||
WHERE is_active = 'Y'
|
||||
AND (
|
||||
(from_table_name = $1 AND to_table_name = $2)
|
||||
OR (from_table_name = $2 AND to_table_name = $1)
|
||||
)
|
||||
`;
|
||||
const params: any[] = [mainTable, detailTable];
|
||||
|
||||
// 관리자가 아닌 경우 회사코드 제한
|
||||
if (companyCode !== "*") {
|
||||
queryText += ` AND (company_code = $3 OR company_code = '*')`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
queryText += ` LIMIT 1`;
|
||||
|
||||
const result = await queryOne<{
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
relationship_type: string;
|
||||
settings: any;
|
||||
}>(queryText, params);
|
||||
|
||||
if (!result) {
|
||||
logger.info(
|
||||
`DataflowService: 테이블 간 조인 관계 없음 - ${mainTable} ↔ ${detailTable}`
|
||||
);
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
// 방향에 따라 컬럼 매핑 결정
|
||||
// mainTable이 from_table이면 그대로, 아니면 반대로
|
||||
let mainColumn: string;
|
||||
let detailColumn: string;
|
||||
|
||||
if (result.from_table_name === mainTable) {
|
||||
// from → to 방향: mainTable.from_column → detailTable.to_column
|
||||
mainColumn = result.from_column_name;
|
||||
detailColumn = result.to_column_name;
|
||||
} else {
|
||||
// to → from 방향: mainTable.to_column → detailTable.from_column
|
||||
mainColumn = result.to_column_name;
|
||||
detailColumn = result.from_column_name;
|
||||
}
|
||||
|
||||
// 쉼표로 구분된 다중 컬럼인 경우 첫 번째 컬럼만 사용
|
||||
// (추후 다중 컬럼 지원 필요시 확장)
|
||||
if (mainColumn.includes(",")) {
|
||||
mainColumn = mainColumn.split(",")[0].trim();
|
||||
}
|
||||
if (detailColumn.includes(",")) {
|
||||
detailColumn = detailColumn.split(",")[0].trim();
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`DataflowService: 조인 관계 발견 - ${mainTable}.${mainColumn} → ${detailTable}.${detailColumn}`
|
||||
);
|
||||
|
||||
return {
|
||||
found: true,
|
||||
mainColumn,
|
||||
detailColumn,
|
||||
relationshipType: result.relationship_type,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("DataflowService: 테이블 간 조인 관계 조회 실패", error);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 타입별 관계 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ export class EntityJoinService {
|
|||
columnName: string;
|
||||
displayName: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
|
|
@ -744,31 +745,39 @@ export class EntityJoinService {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
// 2. column_labels 테이블에서 라벨 정보 조회
|
||||
// 2. column_labels 테이블에서 라벨과 input_type 정보 조회
|
||||
const columnLabels = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
input_type: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label
|
||||
`SELECT column_name, column_label, input_type
|
||||
FROM column_labels
|
||||
WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// 3. 라벨 정보를 맵으로 변환
|
||||
const labelMap = new Map<string, string>();
|
||||
columnLabels.forEach((label) => {
|
||||
if (label.column_name && label.column_label) {
|
||||
labelMap.set(label.column_name, label.column_label);
|
||||
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||
const labelMap = new Map<string, { label: string; inputType: string }>();
|
||||
columnLabels.forEach((col) => {
|
||||
if (col.column_name) {
|
||||
labelMap.set(col.column_name, {
|
||||
label: col.column_label || col.column_name,
|
||||
inputType: col.input_type || "text",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 컬럼 정보와 라벨 정보 결합
|
||||
return columns.map((col) => ({
|
||||
// 4. 컬럼 정보와 라벨/inputType 정보 결합
|
||||
return columns.map((col) => {
|
||||
const labelInfo = labelMap.get(col.column_name);
|
||||
return {
|
||||
columnName: col.column_name,
|
||||
displayName: labelMap.get(col.column_name) || col.column_name, // 라벨이 있으면 사용, 없으면 컬럼명
|
||||
displayName: labelInfo?.label || col.column_name,
|
||||
dataType: col.data_type,
|
||||
}));
|
||||
inputType: labelInfo?.inputType || "text",
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`참조 테이블 컬럼 조회 실패: ${tableName}`, error);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -883,16 +883,21 @@ class MasterDetailExcelService {
|
|||
|
||||
/**
|
||||
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||
* @param client DB 클라이언트
|
||||
* @param ruleId 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
*/
|
||||
private async generateNumberWithRule(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode, formData);
|
||||
|
||||
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export interface MenuCopyResult {
|
|||
copiedCategoryMappings: number;
|
||||
copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정
|
||||
copiedCascadingRelations: number; // 연쇄관계 설정
|
||||
copiedNodeFlows: number; // 노드 플로우 (제어관리)
|
||||
copiedDataflowDiagrams: number; // 데이터플로우 다이어그램 (버튼 제어)
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
@ -983,6 +985,14 @@ export class MenuCopyService {
|
|||
client
|
||||
);
|
||||
|
||||
// === 2.1단계: 노드 플로우 복사는 화면 복사에서 처리 ===
|
||||
// (screenManagementService.ts의 copyScreen에서 처리)
|
||||
const copiedNodeFlows = 0;
|
||||
|
||||
// === 2.2단계: 데이터플로우 다이어그램 복사는 화면 복사에서 처리 ===
|
||||
// (screenManagementService.ts의 copyScreen에서 처리)
|
||||
const copiedDataflowDiagrams = 0;
|
||||
|
||||
// 변수 초기화
|
||||
let copiedCodeCategories = 0;
|
||||
let copiedCodes = 0;
|
||||
|
|
@ -1132,6 +1142,8 @@ export class MenuCopyService {
|
|||
copiedCategoryMappings,
|
||||
copiedTableTypeColumns,
|
||||
copiedCascadingRelations,
|
||||
copiedNodeFlows,
|
||||
copiedDataflowDiagrams,
|
||||
menuIdMap: Object.fromEntries(menuIdMap),
|
||||
screenIdMap: Object.fromEntries(screenIdMap),
|
||||
flowIdMap: Object.fromEntries(flowIdMap),
|
||||
|
|
@ -1144,6 +1156,8 @@ export class MenuCopyService {
|
|||
- 메뉴: ${result.copiedMenus}개
|
||||
- 화면: ${result.copiedScreens}개
|
||||
- 플로우: ${result.copiedFlows}개
|
||||
- 노드 플로우(제어관리): ${copiedNodeFlows}개
|
||||
- 데이터플로우 다이어그램(버튼 제어): ${copiedDataflowDiagrams}개
|
||||
- 코드 카테고리: ${copiedCodeCategories}개
|
||||
- 코드: ${copiedCodes}개
|
||||
- 채번규칙: ${copiedNumberingRules}개
|
||||
|
|
@ -2556,33 +2570,34 @@ export class MenuCopyService {
|
|||
}
|
||||
|
||||
// 4. 배치 INSERT로 채번 규칙 복사
|
||||
if (rulesToCopy.length > 0) {
|
||||
const ruleValues = rulesToCopy
|
||||
// menu 스코프인데 menu_objid 매핑이 없는 규칙은 제외 (연결 없이 복제하지 않음)
|
||||
const validRulesToCopy = rulesToCopy.filter((r) => {
|
||||
if (r.scope_type === "menu") {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.info(` ⏭️ 채번규칙 "${r.rule_name}" 건너뜀: 메뉴 연결 없음 (원본 menu_objid: ${r.menu_objid})`);
|
||||
// ruleIdMap에서도 제거
|
||||
ruleIdMap.delete(r.rule_id);
|
||||
return false; // 복제 대상에서 제외
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validRulesToCopy.length > 0) {
|
||||
const ruleValues = validRulesToCopy
|
||||
.map(
|
||||
(_, i) =>
|
||||
`($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const ruleParams = rulesToCopy.flatMap((r) => {
|
||||
const ruleParams = validRulesToCopy.flatMap((r) => {
|
||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||
// scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건)
|
||||
// menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로
|
||||
// scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리
|
||||
// menu 스코프인 경우 반드시 menu_objid가 있음 (위에서 필터링됨)
|
||||
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
|
||||
// scope_type 결정 로직:
|
||||
// 1. menu 스코프인데 menu_objid 매핑이 없는 경우
|
||||
// - table_name이 있으면 'table' 스코프로 변경
|
||||
// - table_name이 없으면 'global' 스코프로 변경
|
||||
// 2. 그 외에는 원본 scope_type 유지
|
||||
let finalScopeType = r.scope_type;
|
||||
if (r.scope_type === "menu" && finalMenuObjid === null) {
|
||||
if (r.table_name) {
|
||||
finalScopeType = "table"; // table_name이 있으면 table 스코프
|
||||
} else {
|
||||
finalScopeType = "global"; // table_name도 없으면 global 스코프
|
||||
}
|
||||
}
|
||||
// scope_type은 원본 유지 (menu 스코프는 반드시 menu_objid가 있으므로)
|
||||
const finalScopeType = r.scope_type;
|
||||
|
||||
return [
|
||||
r.newRuleId,
|
||||
|
|
@ -2610,8 +2625,8 @@ export class MenuCopyService {
|
|||
ruleParams
|
||||
);
|
||||
|
||||
copiedCount = rulesToCopy.length;
|
||||
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`);
|
||||
copiedCount = validRulesToCopy.length;
|
||||
logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사 (${rulesToCopy.length - validRulesToCopy.length}개 건너뜀)`);
|
||||
}
|
||||
|
||||
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
||||
|
|
@ -3324,4 +3339,175 @@ export class MenuCopyService {
|
|||
logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`);
|
||||
return copiedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 복사 (node_flows 테이블 - 제어관리에서 사용)
|
||||
* - 원본 회사의 모든 node_flows를 대상 회사로 복사
|
||||
* - 대상 회사에 같은 이름의 노드 플로우가 있으면 재사용
|
||||
* - 없으면 새로 복사 (flow_data 포함)
|
||||
* - 원본 ID → 새 ID 매핑 반환 (버튼의 flowId, selectedDiagramId 매핑용)
|
||||
*/
|
||||
private async copyNodeFlows(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; nodeFlowIdMap: Map<number, number> }> {
|
||||
logger.info(`📋 노드 플로우(제어관리) 복사 시작`);
|
||||
const nodeFlowIdMap = new Map<number, number>();
|
||||
let copiedCount = 0;
|
||||
|
||||
// 1. 원본 회사의 모든 node_flows 조회
|
||||
const sourceFlowsResult = await client.query(
|
||||
`SELECT * FROM node_flows WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (sourceFlowsResult.rows.length === 0) {
|
||||
logger.info(` 📭 원본 회사에 노드 플로우 없음`);
|
||||
return { copiedCount: 0, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 📋 원본 노드 플로우: ${sourceFlowsResult.rows.length}개`);
|
||||
|
||||
// 2. 대상 회사의 기존 노드 플로우 조회 (이름 기준)
|
||||
const existingFlowsResult = await client.query(
|
||||
`SELECT flow_id, flow_name FROM node_flows WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingFlowsByName = new Map<string, number>(
|
||||
existingFlowsResult.rows.map((f) => [f.flow_name, f.flow_id])
|
||||
);
|
||||
|
||||
// 3. 복사할 플로우 필터링 + 기존 플로우 매핑
|
||||
const flowsToCopy: any[] = [];
|
||||
for (const flow of sourceFlowsResult.rows) {
|
||||
const existingId = existingFlowsByName.get(flow.flow_name);
|
||||
if (existingId) {
|
||||
// 기존 플로우 재사용 - ID 매핑 추가
|
||||
nodeFlowIdMap.set(flow.flow_id, existingId);
|
||||
logger.info(` ♻️ 기존 노드 플로우 재사용: ${flow.flow_name} (${flow.flow_id} → ${existingId})`);
|
||||
} else {
|
||||
flowsToCopy.push(flow);
|
||||
}
|
||||
}
|
||||
|
||||
if (flowsToCopy.length === 0) {
|
||||
logger.info(` 📭 모든 노드 플로우가 이미 존재함 (매핑 ${nodeFlowIdMap.size}개)`);
|
||||
return { copiedCount: 0, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 🔄 복사할 노드 플로우: ${flowsToCopy.length}개`);
|
||||
|
||||
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
|
||||
for (const flow of flowsToCopy) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO node_flows (flow_name, flow_description, flow_data, company_code)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING flow_id`,
|
||||
[
|
||||
flow.flow_name,
|
||||
flow.flow_description,
|
||||
JSON.stringify(flow.flow_data),
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
|
||||
const newFlowId = insertResult.rows[0].flow_id;
|
||||
nodeFlowIdMap.set(flow.flow_id, newFlowId);
|
||||
logger.info(` ➕ 노드 플로우 복사: ${flow.flow_name} (${flow.flow_id} → ${newFlowId})`);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
logger.info(` ✅ 노드 플로우 복사 완료: ${copiedCount}개, 매핑 ${nodeFlowIdMap.size}개`);
|
||||
|
||||
return { copiedCount, nodeFlowIdMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터플로우 다이어그램 복사 (dataflow_diagrams 테이블 - 버튼 제어 설정에서 사용)
|
||||
* - 원본 회사의 모든 dataflow_diagrams를 대상 회사로 복사
|
||||
* - 대상 회사에 같은 이름의 다이어그램이 있으면 재사용
|
||||
* - 없으면 새로 복사 (relationships, node_positions, control, plan, category 포함)
|
||||
* - 원본 ID → 새 ID 매핑 반환
|
||||
*/
|
||||
private async copyDataflowDiagrams(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<{ copiedCount: number; diagramIdMap: Map<number, number> }> {
|
||||
logger.info(`📋 데이터플로우 다이어그램(버튼 제어) 복사 시작`);
|
||||
const diagramIdMap = new Map<number, number>();
|
||||
let copiedCount = 0;
|
||||
|
||||
// 1. 원본 회사의 모든 dataflow_diagrams 조회
|
||||
const sourceDiagramsResult = await client.query(
|
||||
`SELECT * FROM dataflow_diagrams WHERE company_code = $1`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
if (sourceDiagramsResult.rows.length === 0) {
|
||||
logger.info(` 📭 원본 회사에 데이터플로우 다이어그램 없음`);
|
||||
return { copiedCount: 0, diagramIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 📋 원본 데이터플로우 다이어그램: ${sourceDiagramsResult.rows.length}개`);
|
||||
|
||||
// 2. 대상 회사의 기존 다이어그램 조회 (이름 기준)
|
||||
const existingDiagramsResult = await client.query(
|
||||
`SELECT diagram_id, diagram_name FROM dataflow_diagrams WHERE company_code = $1`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const existingDiagramsByName = new Map<string, number>(
|
||||
existingDiagramsResult.rows.map((d) => [d.diagram_name, d.diagram_id])
|
||||
);
|
||||
|
||||
// 3. 복사할 다이어그램 필터링 + 기존 다이어그램 매핑
|
||||
const diagramsToCopy: any[] = [];
|
||||
for (const diagram of sourceDiagramsResult.rows) {
|
||||
const existingId = existingDiagramsByName.get(diagram.diagram_name);
|
||||
if (existingId) {
|
||||
// 기존 다이어그램 재사용 - ID 매핑 추가
|
||||
diagramIdMap.set(diagram.diagram_id, existingId);
|
||||
logger.info(` ♻️ 기존 다이어그램 재사용: ${diagram.diagram_name} (${diagram.diagram_id} → ${existingId})`);
|
||||
} else {
|
||||
diagramsToCopy.push(diagram);
|
||||
}
|
||||
}
|
||||
|
||||
if (diagramsToCopy.length === 0) {
|
||||
logger.info(` 📭 모든 다이어그램이 이미 존재함 (매핑 ${diagramIdMap.size}개)`);
|
||||
return { copiedCount: 0, diagramIdMap };
|
||||
}
|
||||
|
||||
logger.info(` 🔄 복사할 다이어그램: ${diagramsToCopy.length}개`);
|
||||
|
||||
// 4. 개별 INSERT (RETURNING으로 새 ID 획득)
|
||||
for (const diagram of diagramsToCopy) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO dataflow_diagrams (diagram_name, relationships, company_code, created_by, node_positions, control, plan, category)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING diagram_id`,
|
||||
[
|
||||
diagram.diagram_name,
|
||||
JSON.stringify(diagram.relationships),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
diagram.node_positions ? JSON.stringify(diagram.node_positions) : null,
|
||||
diagram.control ? JSON.stringify(diagram.control) : null,
|
||||
diagram.plan ? JSON.stringify(diagram.plan) : null,
|
||||
diagram.category ? JSON.stringify(diagram.category) : null,
|
||||
]
|
||||
);
|
||||
|
||||
const newDiagramId = insertResult.rows[0].diagram_id;
|
||||
diagramIdMap.set(diagram.diagram_id, newDiagramId);
|
||||
logger.info(` ➕ 다이어그램 복사: ${diagram.diagram_name} (${diagram.diagram_id} → ${newDiagramId})`);
|
||||
copiedCount++;
|
||||
}
|
||||
|
||||
logger.info(` ✅ 데이터플로우 다이어그램 복사 완료: ${copiedCount}개, 매핑 ${diagramIdMap.size}개`);
|
||||
|
||||
return { copiedCount, diagramIdMap };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,28 @@ export async function syncScreenGroupsToMenu(
|
|||
[groupId, menuObjid]
|
||||
);
|
||||
|
||||
// 해당 그룹에 연결된 기본 화면으로 URL 항상 업데이트 (화면 재생성 시에도 반영)
|
||||
const defaultScreenQuery = `
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name
|
||||
FROM screen_group_screens sgs
|
||||
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = $1 AND sgs.company_code = $2
|
||||
ORDER BY
|
||||
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
|
||||
sgs.display_order ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const defaultScreenResult = await client.query(defaultScreenQuery, [groupId, companyCode]);
|
||||
if (defaultScreenResult.rows.length > 0) {
|
||||
const defaultScreen = defaultScreenResult.rows[0];
|
||||
const newMenuUrl = `/screens/${defaultScreen.screen_id}`;
|
||||
await client.query(
|
||||
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
|
||||
[newMenuUrl, defaultScreen.screen_code, menuObjid]
|
||||
);
|
||||
logger.info("메뉴 URL 업데이트", { groupName, screenId: defaultScreen.screen_id, menuUrl: newMenuUrl });
|
||||
}
|
||||
|
||||
groupToMenuMap.set(groupId, menuObjid);
|
||||
result.linked++;
|
||||
result.details.push({
|
||||
|
|
@ -286,12 +308,34 @@ export async function syncScreenGroupsToMenu(
|
|||
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
|
||||
}
|
||||
|
||||
// 해당 그룹에 연결된 기본 화면 조회 (is_default = 'Y' 우선, 없으면 첫 번째 화면)
|
||||
let menuUrl: string | null = null;
|
||||
let screenCode: string | null = null;
|
||||
const defaultScreenQuery2 = `
|
||||
SELECT sd.screen_id, sd.screen_code, sd.screen_name
|
||||
FROM screen_group_screens sgs
|
||||
JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = $1 AND sgs.company_code = $2
|
||||
ORDER BY
|
||||
CASE WHEN sgs.is_default = 'Y' THEN 0 ELSE 1 END,
|
||||
sgs.display_order ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const defaultScreenResult2 = await client.query(defaultScreenQuery2, [groupId, companyCode]);
|
||||
if (defaultScreenResult2.rows.length > 0) {
|
||||
const defaultScreen = defaultScreenResult2.rows[0];
|
||||
screenCode = defaultScreen.screen_code;
|
||||
menuUrl = `/screens/${defaultScreen.screen_id}`;
|
||||
logger.info("기본 화면 URL 설정", { groupName, screenId: defaultScreen.screen_id, menuUrl });
|
||||
}
|
||||
|
||||
// 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)
|
||||
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc,
|
||||
menu_url, screen_code
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9, $10, $11)
|
||||
RETURNING objid
|
||||
`;
|
||||
await client.query(insertMenuQuery, [
|
||||
|
|
@ -304,6 +348,8 @@ export async function syncScreenGroupsToMenu(
|
|||
userId,
|
||||
groupId,
|
||||
group.description || null,
|
||||
menuUrl,
|
||||
screenCode,
|
||||
]);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
|
|
@ -336,7 +382,13 @@ export async function syncScreenGroupsToMenu(
|
|||
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -984,9 +984,11 @@ export class NodeFlowExecutionService {
|
|||
// 자동 생성 (채번 규칙)
|
||||
const companyCode = context.buttonContext?.companyCode || "*";
|
||||
try {
|
||||
// 폼 데이터를 전달하여 날짜 컬럼 기준 생성 지원
|
||||
value = await numberingRuleService.allocateCode(
|
||||
mapping.numberingRuleId,
|
||||
companyCode
|
||||
companyCode,
|
||||
data // 폼 데이터 전달 (날짜 컬럼 기준 생성 시 사용)
|
||||
);
|
||||
console.log(
|
||||
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ interface NumberingRuleConfig {
|
|||
companyCode?: string;
|
||||
menuObjid?: number;
|
||||
scopeType?: string;
|
||||
// 카테고리 조건
|
||||
categoryColumn?: string;
|
||||
categoryValueId?: number;
|
||||
categoryValueLabel?: string; // 조회 시 조인해서 가져옴
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
|
|
@ -854,7 +858,13 @@ class NumberingRuleService {
|
|||
return { ...ruleResult.rows[0], parts };
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("채번 규칙 수정 실패", { error: error.message });
|
||||
logger.error("채번 규칙 수정 실패", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
updates
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
|
|
@ -882,8 +892,15 @@ class NumberingRuleService {
|
|||
|
||||
/**
|
||||
* 코드 미리보기 (순번 증가 없음)
|
||||
* @param ruleId 채번 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
|
||||
*/
|
||||
async previewCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
async previewCode(
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
|
|
@ -891,7 +908,8 @@ class NumberingRuleService {
|
|||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "";
|
||||
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
|
||||
return part.manualConfig?.placeholder || "____";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
|
@ -913,10 +931,23 @@ class NumberingRuleService {
|
|||
|
||||
case "date": {
|
||||
// 날짜 (다양한 날짜 형식)
|
||||
return this.formatDate(
|
||||
new Date(),
|
||||
autoConfig.dateFormat || "YYYYMMDD"
|
||||
);
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
const dateValue = columnValue instanceof Date
|
||||
? columnValue
|
||||
: new Date(columnValue);
|
||||
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
|
|
@ -924,6 +955,71 @@ class NumberingRuleService {
|
|||
return autoConfig.textValue || "TEXT";
|
||||
}
|
||||
|
||||
case "category": {
|
||||
// 카테고리 기반 코드 생성
|
||||
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
|
||||
return "";
|
||||
}
|
||||
|
||||
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
|
||||
// 폼 데이터에서 해당 컬럼의 값 가져오기
|
||||
const selectedValue = formData[columnName];
|
||||
|
||||
logger.info("카테고리 파트 처리", {
|
||||
categoryKey,
|
||||
columnName,
|
||||
selectedValue,
|
||||
formDataKeys: Object.keys(formData),
|
||||
mappingsCount: categoryMappings.length
|
||||
});
|
||||
|
||||
if (!selectedValue) {
|
||||
logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
|
||||
return "";
|
||||
}
|
||||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (UnifiedSelect에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find(
|
||||
(m: any) => {
|
||||
// ID로 매칭
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
// 라벨로 매칭
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel
|
||||
});
|
||||
return mapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
label: m.categoryValueLabel
|
||||
}))
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
|
|
@ -931,14 +1027,21 @@ class NumberingRuleService {
|
|||
});
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode });
|
||||
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
|
||||
return previewCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 할당 (저장 시점에 실제 순번 증가)
|
||||
* @param ruleId 채번 규칙 ID
|
||||
* @param companyCode 회사 코드
|
||||
* @param formData 폼 데이터 (날짜 컬럼 기준 생성 시 사용)
|
||||
*/
|
||||
async allocateCode(ruleId: string, companyCode: string): Promise<string> {
|
||||
async allocateCode(
|
||||
ruleId: string,
|
||||
companyCode: string,
|
||||
formData?: Record<string, any>
|
||||
): Promise<string> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
|
|
@ -974,10 +1077,40 @@ class NumberingRuleService {
|
|||
|
||||
case "date": {
|
||||
// 날짜 (다양한 날짜 형식)
|
||||
return this.formatDate(
|
||||
new Date(),
|
||||
autoConfig.dateFormat || "YYYYMMDD"
|
||||
);
|
||||
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
|
||||
const columnValue = formData[autoConfig.sourceColumnName];
|
||||
if (columnValue) {
|
||||
// 날짜 문자열 또는 Date 객체를 Date로 변환
|
||||
const dateValue = columnValue instanceof Date
|
||||
? columnValue
|
||||
: new Date(columnValue);
|
||||
|
||||
if (!isNaN(dateValue.getTime())) {
|
||||
logger.info("컬럼 기준 날짜 생성", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
columnValue,
|
||||
parsedDate: dateValue.toISOString(),
|
||||
});
|
||||
return this.formatDate(dateValue, dateFormat);
|
||||
} else {
|
||||
logger.warn("날짜 변환 실패, 현재 날짜 사용", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
columnValue,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn("소스 컬럼 값이 없음, 현재 날짜 사용", {
|
||||
sourceColumn: autoConfig.sourceColumnName,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기본: 현재 날짜 사용
|
||||
return this.formatDate(new Date(), dateFormat);
|
||||
}
|
||||
|
||||
case "text": {
|
||||
|
|
@ -1062,6 +1195,685 @@ class NumberingRuleService {
|
|||
);
|
||||
logger.info("시퀀스 초기화 완료", { ruleId, companyCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* [테스트] 테이블명 + 컬럼명 기반으로 채번규칙 조회 (menu_objid 없이)
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async getNumberingRuleByColumn(
|
||||
companyCode: string,
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<NumberingRuleConfig | null> {
|
||||
try {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 시작 (테스트)", {
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
AND r.category_value_id IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
const params = [companyCode, tableName, columnName];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = result.rows[0];
|
||||
|
||||
// 파트 정보 조회 (테스트 테이블)
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
ruleId: rule.ruleId,
|
||||
ruleName: rule.ruleName,
|
||||
});
|
||||
return rule;
|
||||
} catch (error: any) {
|
||||
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패 (테스트)", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에 채번규칙 저장
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async saveRuleToTest(
|
||||
config: NumberingRuleConfig,
|
||||
companyCode: string,
|
||||
createdBy: string
|
||||
): Promise<NumberingRuleConfig> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
logger.info("테스트 테이블에 채번 규칙 저장 시작", {
|
||||
ruleId: config.ruleId,
|
||||
ruleName: config.ruleName,
|
||||
tableName: config.tableName,
|
||||
columnName: config.columnName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 기존 규칙 확인
|
||||
const existingQuery = `
|
||||
SELECT rule_id FROM numbering_rules_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
`;
|
||||
const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
// 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE numbering_rules_test SET
|
||||
rule_name = $1,
|
||||
description = $2,
|
||||
separator = $3,
|
||||
reset_period = $4,
|
||||
table_name = $5,
|
||||
column_name = $6,
|
||||
category_column = $7,
|
||||
category_value_id = $8,
|
||||
updated_at = NOW()
|
||||
WHERE rule_id = $9 AND company_code = $10
|
||||
`;
|
||||
await client.query(updateQuery, [
|
||||
config.ruleName,
|
||||
config.description || "",
|
||||
config.separator || "-",
|
||||
config.resetPeriod || "none",
|
||||
config.tableName || "",
|
||||
config.columnName || "",
|
||||
config.categoryColumn || null,
|
||||
config.categoryValueId || null,
|
||||
config.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 기존 파트 삭제
|
||||
await client.query(
|
||||
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[config.ruleId, companyCode]
|
||||
);
|
||||
} else {
|
||||
// 신규 등록
|
||||
const insertQuery = `
|
||||
INSERT INTO numbering_rules_test (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
category_column, category_value_id,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), $12)
|
||||
`;
|
||||
await client.query(insertQuery, [
|
||||
config.ruleId,
|
||||
config.ruleName,
|
||||
config.description || "",
|
||||
config.separator || "-",
|
||||
config.resetPeriod || "none",
|
||||
config.currentSequence || 1,
|
||||
config.tableName || "",
|
||||
config.columnName || "",
|
||||
companyCode,
|
||||
config.categoryColumn || null,
|
||||
config.categoryValueId || null,
|
||||
createdBy,
|
||||
]);
|
||||
}
|
||||
|
||||
// 파트 저장
|
||||
if (config.parts && config.parts.length > 0) {
|
||||
for (const part of config.parts) {
|
||||
const partInsertQuery = `
|
||||
INSERT INTO numbering_rule_parts_test (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`;
|
||||
await client.query(partInsertQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("테스트 테이블에 채번 규칙 저장 완료", {
|
||||
ruleId: config.ruleId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("테스트 테이블에 채번 규칙 저장 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
ruleId: config.ruleId,
|
||||
companyCode,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [테스트] 테스트 테이블에서 채번규칙 삭제
|
||||
* numbering_rules_test 테이블 사용
|
||||
*/
|
||||
async deleteRuleFromTest(ruleId: string, companyCode: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode });
|
||||
|
||||
// 파트 먼저 삭제
|
||||
await client.query(
|
||||
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
|
||||
// 규칙 삭제
|
||||
const result = await client.query(
|
||||
"DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2",
|
||||
[ruleId, companyCode]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("테스트 테이블에서 채번 규칙 삭제 완료", {
|
||||
ruleId,
|
||||
companyCode,
|
||||
deletedCount: result.rowCount,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("테스트 테이블에서 채번 규칙 삭제 실패", {
|
||||
error: error.message,
|
||||
ruleId,
|
||||
companyCode,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [테스트] 카테고리 값에 따라 적절한 채번규칙 조회
|
||||
* 1. 해당 카테고리 값에 매칭되는 규칙 찾기
|
||||
* 2. 없으면 기본 규칙(category_value_id가 NULL인) 찾기
|
||||
*/
|
||||
async getNumberingRuleByColumnWithCategory(
|
||||
companyCode: string,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
categoryColumn?: string,
|
||||
categoryValueId?: number
|
||||
): Promise<NumberingRuleConfig | null> {
|
||||
try {
|
||||
logger.info("카테고리 조건 포함 채번 규칙 조회 시작", {
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
categoryColumn,
|
||||
categoryValueId,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 카테고리 값에 매칭되는 규칙 찾기
|
||||
if (categoryColumn && categoryValueId) {
|
||||
const categoryQuery = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
AND r.category_column = $4
|
||||
AND r.category_value_id = $5
|
||||
LIMIT 1
|
||||
`;
|
||||
const categoryResult = await pool.query(categoryQuery, [
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
categoryColumn,
|
||||
categoryValueId,
|
||||
]);
|
||||
|
||||
if (categoryResult.rows.length > 0) {
|
||||
const rule = categoryResult.rows[0];
|
||||
// 파트 정보 조회
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
|
||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||
ruleId: rule.ruleId,
|
||||
categoryValueLabel: rule.categoryValueLabel,
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 기본 규칙 찾기 (category_value_id가 NULL인)
|
||||
const defaultQuery = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules_test r
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
AND r.category_value_id IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]);
|
||||
|
||||
if (defaultResult.rows.length > 0) {
|
||||
const rule = defaultResult.rows[0];
|
||||
// 파트 정보 조회
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
|
||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||
ruleId: rule.ruleId,
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
logger.info("채번 규칙을 찾을 수 없음", {
|
||||
companyCode,
|
||||
tableName,
|
||||
columnName,
|
||||
categoryColumn,
|
||||
categoryValueId,
|
||||
});
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error("카테고리 조건 포함 채번 규칙 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [테스트] 특정 테이블.컬럼의 모든 채번 규칙 조회 (카테고리 조건별)
|
||||
*/
|
||||
async getRulesByTableColumn(
|
||||
companyCode: string,
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
r.rule_id AS "ruleId",
|
||||
r.rule_name AS "ruleName",
|
||||
r.description,
|
||||
r.separator,
|
||||
r.reset_period AS "resetPeriod",
|
||||
r.current_sequence AS "currentSequence",
|
||||
r.table_name AS "tableName",
|
||||
r.column_name AS "columnName",
|
||||
r.company_code AS "companyCode",
|
||||
r.category_column AS "categoryColumn",
|
||||
r.category_value_id AS "categoryValueId",
|
||||
cv.value_label AS "categoryValueLabel",
|
||||
r.created_at AS "createdAt",
|
||||
r.updated_at AS "updatedAt",
|
||||
r.created_by AS "createdBy"
|
||||
FROM numbering_rules_test r
|
||||
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
|
||||
WHERE r.company_code = $1
|
||||
AND r.table_name = $2
|
||||
AND r.column_name = $3
|
||||
ORDER BY r.category_value_id NULLS FIRST, r.created_at
|
||||
`;
|
||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
||||
|
||||
// 각 규칙의 파트 정보 조회
|
||||
for (const rule of result.rows) {
|
||||
const partsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
part_order AS "order",
|
||||
part_type AS "partType",
|
||||
generation_method AS "generationMethod",
|
||||
auto_config AS "autoConfig",
|
||||
manual_config AS "manualConfig"
|
||||
FROM numbering_rule_parts_test
|
||||
WHERE rule_id = $1 AND company_code = $2
|
||||
ORDER BY part_order
|
||||
`;
|
||||
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
|
||||
rule.parts = partsResult.rows;
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", {
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출)
|
||||
* 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결
|
||||
* 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트
|
||||
*/
|
||||
async copyRulesForCompany(
|
||||
sourceCompanyCode: string,
|
||||
targetCompanyCode: string
|
||||
): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record<string, string> }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record<string, string> };
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두)
|
||||
const sourceRulesResult = await client.query(
|
||||
`SELECT nr.*, mi.menu_name_kor as source_menu_name
|
||||
FROM numbering_rules nr
|
||||
LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid
|
||||
WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`,
|
||||
[sourceCompanyCode]
|
||||
);
|
||||
|
||||
logger.info("원본 채번규칙 조회", {
|
||||
sourceCompanyCode,
|
||||
count: sourceRulesResult.rowCount
|
||||
});
|
||||
|
||||
// 2. 각 채번규칙 복제
|
||||
for (const rule of sourceRulesResult.rows) {
|
||||
// 새 rule_id 생성
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 이미 존재하는지 확인 (이름 기반)
|
||||
const existsCheck = await client.query(
|
||||
`SELECT rule_id FROM numbering_rules
|
||||
WHERE company_code = $1 AND rule_name = $2`,
|
||||
[targetCompanyCode, rule.rule_name]
|
||||
);
|
||||
|
||||
if (existsCheck.rows.length > 0) {
|
||||
// 이미 존재하면 매핑만 추가
|
||||
result.ruleIdMap[rule.rule_id] = existsCheck.rows[0].rule_id;
|
||||
result.skippedCount++;
|
||||
result.details.push(`건너뜀 (이미 존재): ${rule.rule_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetMenuObjid = null;
|
||||
|
||||
// menu 스코프인 경우 대상 메뉴 찾기
|
||||
if (rule.scope_type === 'menu' && rule.source_menu_name) {
|
||||
const targetMenuResult = await client.query(
|
||||
`SELECT objid FROM menu_info
|
||||
WHERE company_code = $1 AND menu_name_kor = $2
|
||||
LIMIT 1`,
|
||||
[targetCompanyCode, rule.source_menu_name]
|
||||
);
|
||||
|
||||
if (targetMenuResult.rows.length === 0) {
|
||||
result.skippedCount++;
|
||||
result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
targetMenuObjid = targetMenuResult.rows[0].objid;
|
||||
}
|
||||
|
||||
// 채번규칙 복제
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator, reset_period,
|
||||
current_sequence, table_name, column_name, company_code,
|
||||
created_at, updated_at, created_by, scope_type, menu_objid
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
rule.rule_name,
|
||||
rule.description,
|
||||
rule.separator,
|
||||
rule.reset_period,
|
||||
0, // 시퀀스 초기화
|
||||
rule.table_name,
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
rule.created_by,
|
||||
rule.scope_type,
|
||||
targetMenuObjid,
|
||||
]
|
||||
);
|
||||
|
||||
// 채번규칙 파트 복제
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
|
||||
[rule.rule_id]
|
||||
);
|
||||
|
||||
for (const part of partsResult.rows) {
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||
[
|
||||
newRuleId,
|
||||
part.part_order,
|
||||
part.part_type,
|
||||
part.generation_method,
|
||||
part.auto_config ? JSON.stringify(part.auto_config) : null,
|
||||
part.manual_config ? JSON.stringify(part.manual_config) : null,
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// 매핑 추가
|
||||
result.ruleIdMap[rule.rule_id] = newRuleId;
|
||||
result.copiedCount++;
|
||||
result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`);
|
||||
logger.info("채번규칙 복제 완료", {
|
||||
ruleName: rule.rule_name,
|
||||
oldRuleId: rule.rule_id,
|
||||
newRuleId,
|
||||
targetMenuObjid
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 화면 레이아웃의 numberingRuleId 참조 업데이트
|
||||
if (Object.keys(result.ruleIdMap).length > 0) {
|
||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", {
|
||||
targetCompanyCode,
|
||||
mappingCount: Object.keys(result.ruleIdMap).length
|
||||
});
|
||||
|
||||
// 대상 회사의 모든 화면 레이아웃 조회
|
||||
const layoutsResult = await client.query(
|
||||
`SELECT sl.layout_id, sl.properties
|
||||
FROM screen_layouts sl
|
||||
JOIN screen_definitions sd ON sl.screen_id = sd.screen_id
|
||||
WHERE sd.company_code = $1
|
||||
AND sl.properties::text LIKE '%numberingRuleId%'`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
|
||||
let updatedLayouts = 0;
|
||||
|
||||
for (const layout of layoutsResult.rows) {
|
||||
let propsStr = JSON.stringify(layout.properties);
|
||||
let updated = false;
|
||||
|
||||
// 각 매핑에 대해 치환
|
||||
for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) {
|
||||
if (propsStr.includes(`"${oldRuleId}"`)) {
|
||||
propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
await client.query(
|
||||
`UPDATE screen_layouts SET properties = $1::jsonb WHERE layout_id = $2`,
|
||||
[propsStr, layout.layout_id]
|
||||
);
|
||||
updatedLayouts++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", {
|
||||
targetCompanyCode,
|
||||
updatedLayouts
|
||||
});
|
||||
result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("회사별 채번규칙 복제 완료", {
|
||||
sourceCompanyCode,
|
||||
targetCompanyCode,
|
||||
copiedCount: result.copiedCount,
|
||||
skippedCount: result.skippedCount,
|
||||
ruleIdMapCount: Object.keys(result.ruleIdMap).length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const numberingRuleService = new NumberingRuleService();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -207,48 +207,27 @@ class TableCategoryValueService {
|
|||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
NULL::numeric AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
FROM category_values_test
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
// category_values_test 테이블 사용 (menu_objid 없음)
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
|
||||
params = [tableName, columnName, siblingObjids];
|
||||
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND menu_objid = $3`;
|
||||
params = [tableName, columnName, menuObjid];
|
||||
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 모든 값 조회 (중복 가능)
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
|
||||
}
|
||||
// 최고 관리자: 모든 값 조회
|
||||
query = baseSelect;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 + menuObjid로 필터링
|
||||
if (menuObjid && siblingObjids.length > 0) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
|
||||
params = [tableName, columnName, companyCode, siblingObjids];
|
||||
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
|
||||
} else if (menuObjid) {
|
||||
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
|
||||
params = [tableName, columnName, companyCode, menuObjid];
|
||||
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
|
||||
} else {
|
||||
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
|
||||
query = baseSelect + ` AND company_code = $3`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
|
||||
}
|
||||
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
|
||||
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ export class TableManagementService {
|
|||
tableName: string,
|
||||
page: number = 1,
|
||||
size: number = 50,
|
||||
companyCode?: string // 🔥 회사 코드 추가
|
||||
companyCode?: string, // 🔥 회사 코드 추가
|
||||
bustCache: boolean = false // 🔥 캐시 버스팅 옵션
|
||||
): Promise<{
|
||||
columns: ColumnTypeInfo[];
|
||||
total: number;
|
||||
|
|
@ -124,7 +125,7 @@ export class TableManagementService {
|
|||
}> {
|
||||
try {
|
||||
logger.info(
|
||||
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}`
|
||||
`컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode}, bustCache: ${bustCache}`
|
||||
);
|
||||
|
||||
// 캐시 키 생성 (companyCode 포함)
|
||||
|
|
@ -132,32 +133,37 @@ export class TableManagementService {
|
|||
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
|
||||
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
|
||||
|
||||
// 캐시에서 먼저 확인
|
||||
const cachedResult = cache.get<{
|
||||
columns: ColumnTypeInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}>(cacheKey);
|
||||
if (cachedResult) {
|
||||
logger.info(
|
||||
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
||||
);
|
||||
// 🔥 캐시 버스팅: bustCache가 true면 캐시 무시
|
||||
if (!bustCache) {
|
||||
// 캐시에서 먼저 확인
|
||||
const cachedResult = cache.get<{
|
||||
columns: ColumnTypeInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}>(cacheKey);
|
||||
if (cachedResult) {
|
||||
logger.info(
|
||||
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
||||
);
|
||||
|
||||
// 디버깅: 캐시된 currency_code 확인
|
||||
const cachedCurrency = cachedResult.columns.find(
|
||||
(col: any) => col.columnName === "currency_code"
|
||||
);
|
||||
if (cachedCurrency) {
|
||||
console.log(`💾 [캐시] currency_code:`, {
|
||||
columnName: cachedCurrency.columnName,
|
||||
inputType: cachedCurrency.inputType,
|
||||
webType: cachedCurrency.webType,
|
||||
});
|
||||
// 디버깅: 캐시된 currency_code 확인
|
||||
const cachedCurrency = cachedResult.columns.find(
|
||||
(col: any) => col.columnName === "currency_code"
|
||||
);
|
||||
if (cachedCurrency) {
|
||||
console.log(`💾 [캐시] currency_code:`, {
|
||||
columnName: cachedCurrency.columnName,
|
||||
inputType: cachedCurrency.inputType,
|
||||
webType: cachedCurrency.webType,
|
||||
});
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
} else {
|
||||
logger.info(`🔥 캐시 버스팅: ${tableName} 캐시 무시`);
|
||||
}
|
||||
|
||||
// 전체 컬럼 수 조회 (캐시 확인)
|
||||
|
|
@ -4105,12 +4111,17 @@ export class TableManagementService {
|
|||
|
||||
// table_type_columns에서 입력타입 정보 조회
|
||||
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
|
||||
// detail_settings 컬럼에 유효하지 않은 JSON이 있을 수 있으므로 안전하게 처리
|
||||
const rawInputTypes = await query<any>(
|
||||
`SELECT DISTINCT ON (ttc.column_name)
|
||||
ttc.column_name as "columnName",
|
||||
COALESCE(cl.column_label, ttc.column_name) as "displayName",
|
||||
ttc.input_type as "inputType",
|
||||
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
|
||||
CASE
|
||||
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
|
||||
WHEN ttc.detail_settings ~ '^\\s*\\{.*\\}\\s*$' THEN ttc.detail_settings::jsonb
|
||||
ELSE '{}'::jsonb
|
||||
END as "detailSettings",
|
||||
ttc.is_nullable as "isNullable",
|
||||
ic.data_type as "dataType",
|
||||
ttc.company_code as "companyCode"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,568 @@
|
|||
# V2 컴포넌트 및 Unified 폼 컴포넌트 결합도 분석 보고서
|
||||
|
||||
> 작성일: 2026-01-26
|
||||
> 목적: 컴포넌트 간 결합도 분석 및 느슨한 결합 전환 가능성 평가
|
||||
|
||||
---
|
||||
|
||||
## 1. 분석 대상 컴포넌트 목록
|
||||
|
||||
### 1.1 V2 컴포넌트 (18개)
|
||||
|
||||
| # | 컴포넌트 | 경로 | 주요 용도 |
|
||||
|---|---------|------|----------|
|
||||
| 1 | v2-aggregation-widget | `v2-aggregation-widget/` | 데이터 집계 표시 |
|
||||
| 2 | v2-button-primary | `v2-button-primary/` | 기본 버튼 (저장/삭제/모달 등) |
|
||||
| 3 | v2-card-display | `v2-card-display/` | 카드 형태 데이터 표시 |
|
||||
| 4 | v2-category-manager | `v2-category-manager/` | 카테고리 트리 관리 |
|
||||
| 5 | v2-divider-line | `v2-divider-line/` | 구분선 |
|
||||
| 6 | v2-location-swap-selector | `v2-location-swap-selector/` | 출발지/도착지 선택 |
|
||||
| 7 | v2-numbering-rule | `v2-numbering-rule/` | 채번 규칙 표시 |
|
||||
| 8 | v2-pivot-grid | `v2-pivot-grid/` | 피벗 테이블 |
|
||||
| 9 | v2-rack-structure | `v2-rack-structure/` | 렉 구조 표시 |
|
||||
| 10 | v2-repeat-container | `v2-repeat-container/` | 리피터 컨테이너 |
|
||||
| 11 | v2-repeat-screen-modal | `v2-repeat-screen-modal/` | 반복 화면 모달 |
|
||||
| 12 | v2-section-card | `v2-section-card/` | 섹션 카드 |
|
||||
| 13 | v2-section-paper | `v2-section-paper/` | 섹션 페이퍼 |
|
||||
| 14 | v2-split-panel-layout | `v2-split-panel-layout/` | 분할 패널 레이아웃 |
|
||||
| 15 | v2-table-list | `v2-table-list/` | 테이블 리스트 |
|
||||
| 16 | v2-table-search-widget | `v2-table-search-widget/` | 테이블 검색 위젯 |
|
||||
| 17 | v2-tabs-widget | `v2-tabs-widget/` | 탭 위젯 |
|
||||
| 18 | v2-text-display | `v2-text-display/` | 텍스트 표시 |
|
||||
| 19 | v2-unified-repeater | `v2-unified-repeater/` | 통합 리피터 |
|
||||
|
||||
### 1.2 Unified 폼 컴포넌트 (11개)
|
||||
|
||||
| # | 컴포넌트 | 파일 | 주요 용도 |
|
||||
|---|---------|------|----------|
|
||||
| 1 | UnifiedInput | `UnifiedInput.tsx` | 텍스트/숫자/이메일 등 입력 |
|
||||
| 2 | UnifiedSelect | `UnifiedSelect.tsx` | 선택박스/라디오/체크박스 |
|
||||
| 3 | UnifiedDate | `UnifiedDate.tsx` | 날짜/시간 입력 |
|
||||
| 4 | UnifiedRepeater | `UnifiedRepeater.tsx` | 리피터 (테이블 형태) |
|
||||
| 5 | UnifiedLayout | `UnifiedLayout.tsx` | 레이아웃 컨테이너 |
|
||||
| 6 | UnifiedGroup | `UnifiedGroup.tsx` | 그룹 컨테이너 (카드/탭/접기) |
|
||||
| 7 | UnifiedHierarchy | `UnifiedHierarchy.tsx` | 계층 구조 표시 |
|
||||
| 8 | UnifiedList | `UnifiedList.tsx` | 리스트 표시 |
|
||||
| 9 | UnifiedMedia | `UnifiedMedia.tsx` | 파일/이미지/비디오 업로드 |
|
||||
| 10 | UnifiedBiz | `UnifiedBiz.tsx` | 비즈니스 컴포넌트 |
|
||||
| 11 | UnifiedFormContext | `UnifiedFormContext.tsx` | 폼 상태 관리 컨텍스트 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 결합도 분석 결과
|
||||
|
||||
### 2.1 결합도 유형 분류
|
||||
|
||||
| 유형 | 설명 | 문제점 |
|
||||
|------|------|--------|
|
||||
| **직접 Import** | 다른 모듈을 직접 import하여 사용 | 변경 시 영향 범위 큼 |
|
||||
| **CustomEvent** | window.dispatchEvent로 이벤트 발생/수신 | 암묵적 의존성, 타입 안전성 부족 |
|
||||
| **전역 상태 (window.__)** | window 객체에 전역 변수 저장 | 네임스페이스 충돌, 테스트 어려움 |
|
||||
| **Context API** | React Context로 상태 공유 | 상대적으로 안전하지만 범위 확장 시 주의 |
|
||||
|
||||
### 2.2 V2 컴포넌트 결합도 상세
|
||||
|
||||
#### 2.2.1 높은 결합도 (High Coupling) - 우선 개선 대상
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **v2-button-primary** | ✅ 직접 Import | 4개 발생 | ❌ | 🔴 8/10 |
|
||||
| **v2-table-list** | ❌ | 16개 수신/발생 | 4개 사용 | 🔴 9/10 |
|
||||
|
||||
**v2-button-primary 상세:**
|
||||
```typescript
|
||||
// 직접 의존
|
||||
import { ButtonActionExecutor, ButtonActionContext } from "@/lib/utils/buttonActions";
|
||||
|
||||
// CustomEvent 발생
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||
```
|
||||
|
||||
**v2-table-list 상세:**
|
||||
```typescript
|
||||
// 전역 상태 사용
|
||||
window.__relatedButtonsTargetTables
|
||||
window.__relatedButtonsSelectedData
|
||||
|
||||
// CustomEvent 발생
|
||||
window.dispatchEvent(new CustomEvent("tableListDataChange", { ... }));
|
||||
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("refreshTable", handleRefreshTable);
|
||||
window.addEventListener("related-button-register", ...);
|
||||
window.addEventListener("related-button-unregister", ...);
|
||||
window.addEventListener("related-button-select", ...);
|
||||
```
|
||||
|
||||
#### 2.2.2 중간 결합도 (Medium Coupling)
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **v2-repeat-container** | ❌ | 5개 수신/발생 | ❌ | 🟠 6/10 |
|
||||
| **v2-split-panel-layout** | ❌ | 3개 수신/발생 | ❌ | 🟠 5/10 |
|
||||
| **v2-aggregation-widget** | ❌ | 14개 수신 | ❌ | 🟠 6/10 |
|
||||
| **v2-tabs-widget** | ❌ | 2개 | ❌ | 🟠 4/10 |
|
||||
|
||||
**v2-repeat-container 상세:**
|
||||
```typescript
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
window.addEventListener("repeaterDataChange", handleDataChange);
|
||||
window.addEventListener("tableListDataChange", handleDataChange);
|
||||
```
|
||||
|
||||
**v2-aggregation-widget 상세:**
|
||||
```typescript
|
||||
// CustomEvent 수신 (다수)
|
||||
window.addEventListener("tableListDataChange", handleTableListDataChange);
|
||||
window.addEventListener("repeaterDataChange", handleRepeaterDataChange);
|
||||
window.addEventListener("selectionChange", handleSelectionChange);
|
||||
window.addEventListener("tableSelectionChange", handleSelectionChange);
|
||||
window.addEventListener("rowSelectionChange", handleSelectionChange);
|
||||
window.addEventListener("checkboxSelectionChange", handleSelectionChange);
|
||||
```
|
||||
|
||||
#### 2.2.3 낮은 결합도 (Low Coupling) - 독립적
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| v2-pivot-grid | ❌ | 0개 | window.open만 | 🟢 2/10 |
|
||||
| v2-card-display | ❌ | 1개 수신 | ❌ | 🟢 2/10 |
|
||||
| v2-category-manager | ❌ | 2개 (ConfigPanel) | ❌ | 🟢 2/10 |
|
||||
| v2-divider-line | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-location-swap-selector | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-numbering-rule | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-rack-structure | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-section-card | ❌ | 1개 (ConfigPanel) | ❌ | 🟢 1/10 |
|
||||
| v2-section-paper | ❌ | 1개 (ConfigPanel) | ❌ | 🟢 1/10 |
|
||||
| v2-table-search-widget | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-text-display | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-repeat-screen-modal | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| v2-unified-repeater | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
|
||||
### 2.3 Unified 폼 컴포넌트 결합도 상세
|
||||
|
||||
| 컴포넌트 | buttonActions Import | CustomEvent 사용 | window.__ 사용 | 결합도 점수 |
|
||||
|---------|---------------------|------------------|----------------|------------|
|
||||
| **UnifiedRepeater** | ❌ | 7개 수신/발생 | 2개 사용 | 🔴 8/10 |
|
||||
| **UnifiedFormContext** | ❌ | 3개 발생 | ❌ | 🟠 4/10 |
|
||||
| UnifiedInput | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedSelect | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedDate | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedLayout | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedGroup | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedHierarchy | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedList | ❌ | 0개 (TableList 래핑) | ❌ | 🟢 2/10 |
|
||||
| UnifiedMedia | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
| UnifiedBiz | ❌ | 0개 | ❌ | 🟢 1/10 |
|
||||
|
||||
**UnifiedRepeater 상세:**
|
||||
```typescript
|
||||
// 전역 상태 사용
|
||||
window.__unifiedRepeaterInstances = new Set();
|
||||
window.__unifiedRepeaterInstances.add(targetTableName);
|
||||
|
||||
// CustomEvent 수신
|
||||
window.addEventListener("repeaterSave", handleSaveEvent);
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
window.addEventListener("componentDataTransfer", handleComponentDataTransfer);
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer);
|
||||
```
|
||||
|
||||
**UnifiedFormContext 상세:**
|
||||
```typescript
|
||||
// CustomEvent 발생 (레거시 호환)
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave", { detail: eventDetail }));
|
||||
window.dispatchEvent(new CustomEvent("afterFormSave", { detail: { ... } }));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 주요 결합 지점 시각화
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ buttonActions.ts (7,145줄) │
|
||||
│ ⬇️ 직접 Import │
|
||||
│ v2-button-primary ───────────────────────────────────────────────┐
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ CustomEvent
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Event Bus (현재: window) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ refreshTable │ │beforeFormSave│ │tableListData │ │
|
||||
│ │ │ │ │ │ Change │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────│──────────────────│──────────────────│─────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────┐ ┌───────────────┐
|
||||
│v2-table │ │v2-repeat │ │v2-aggregation │
|
||||
│ -list │ │-container │ │ -widget │
|
||||
└───────────┘ └───────────┘ └───────────────┘
|
||||
│ │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────┐ ┌───────────┐
|
||||
│Unified │ │Unified │
|
||||
│Repeater │ │FormContext│
|
||||
└───────────┘ └───────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 이벤트 매트릭스
|
||||
|
||||
### 4.1 이벤트 발생 컴포넌트
|
||||
|
||||
| 이벤트명 | 발생 컴포넌트 | 용도 |
|
||||
|---------|-------------|------|
|
||||
| `refreshTable` | v2-button-primary, buttonActions | 테이블 데이터 새로고침 |
|
||||
| `closeEditModal` | v2-button-primary, buttonActions | 수정 모달 닫기 |
|
||||
| `saveSuccessInModal` | v2-button-primary, buttonActions | 저장 성공 알림 (연속 등록) |
|
||||
| `beforeFormSave` | UnifiedFormContext, buttonActions | 저장 전 데이터 수집 |
|
||||
| `afterFormSave` | UnifiedFormContext | 저장 완료 알림 |
|
||||
| `tableListDataChange` | v2-table-list | 테이블 데이터 변경 알림 |
|
||||
| `repeaterDataChange` | UnifiedRepeater | 리피터 데이터 변경 알림 |
|
||||
| `repeaterSave` | buttonActions | 리피터 저장 요청 |
|
||||
| `openScreenModal` | v2-split-panel-layout | 화면 모달 열기 |
|
||||
| `refreshCardDisplay` | buttonActions | 카드 디스플레이 새로고침 |
|
||||
|
||||
### 4.2 이벤트 수신 컴포넌트
|
||||
|
||||
| 이벤트명 | 수신 컴포넌트 | 처리 내용 |
|
||||
|---------|-------------|----------|
|
||||
| `refreshTable` | v2-table-list, v2-split-panel-layout | 데이터 재조회 |
|
||||
| `beforeFormSave` | v2-repeat-container, UnifiedRepeater | formData에 섹션 데이터 추가 |
|
||||
| `tableListDataChange` | v2-aggregation-widget, v2-repeat-container | 집계 재계산, 데이터 동기화 |
|
||||
| `repeaterDataChange` | v2-aggregation-widget, v2-repeat-container | 집계 재계산, 데이터 동기화 |
|
||||
| `repeaterSave` | UnifiedRepeater | 리피터 데이터 저장 실행 |
|
||||
| `selectionChange` | v2-aggregation-widget | 선택 기반 집계 |
|
||||
| `componentDataTransfer` | UnifiedRepeater | 컴포넌트 간 데이터 전달 |
|
||||
| `splitPanelDataTransfer` | UnifiedRepeater | 분할 패널 데이터 전달 |
|
||||
| `refreshCardDisplay` | v2-card-display | 카드 데이터 재조회 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 전역 상태 사용 현황
|
||||
|
||||
| 전역 변수 | 사용 컴포넌트 | 용도 | 위험도 |
|
||||
|----------|-------------|------|--------|
|
||||
| `window.__unifiedRepeaterInstances` | UnifiedRepeater, buttonActions | 리피터 인스턴스 추적 | 🟠 중간 |
|
||||
| `window.__relatedButtonsTargetTables` | v2-table-list | 관련 버튼 대상 테이블 | 🟠 중간 |
|
||||
| `window.__relatedButtonsSelectedData` | v2-table-list, buttonActions | 관련 버튼 선택 데이터 | 🟠 중간 |
|
||||
| `window.__dataRegistry` | v2-table-list (v1/v2) | 테이블 데이터 레지스트리 | 🟠 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 결합도 요약 점수
|
||||
|
||||
### 6.1 V2 컴포넌트 (18개)
|
||||
|
||||
| 결합도 수준 | 개수 | 컴포넌트 |
|
||||
|------------|------|---------|
|
||||
| 🔴 높음 (7-10점) | 2개 | v2-button-primary, v2-table-list |
|
||||
| 🟠 중간 (4-6점) | 4개 | v2-repeat-container, v2-split-panel-layout, v2-aggregation-widget, v2-tabs-widget |
|
||||
| 🟢 낮음 (1-3점) | 12개 | 나머지 |
|
||||
|
||||
### 6.2 Unified 컴포넌트 (11개)
|
||||
|
||||
| 결합도 수준 | 개수 | 컴포넌트 |
|
||||
|------------|------|---------|
|
||||
| 🔴 높음 (7-10점) | 1개 | UnifiedRepeater |
|
||||
| 🟠 중간 (4-6점) | 1개 | UnifiedFormContext |
|
||||
| 🟢 낮음 (1-3점) | 9개 | 나머지 |
|
||||
|
||||
### 6.3 전체 결합도 분포
|
||||
|
||||
```
|
||||
전체 29개 컴포넌트
|
||||
|
||||
높은 결합도 (🔴): 3개 (10.3%)
|
||||
├── v2-button-primary
|
||||
├── v2-table-list
|
||||
└── UnifiedRepeater
|
||||
|
||||
중간 결합도 (🟠): 5개 (17.2%)
|
||||
├── v2-repeat-container
|
||||
├── v2-split-panel-layout
|
||||
├── v2-aggregation-widget
|
||||
├── v2-tabs-widget
|
||||
└── UnifiedFormContext
|
||||
|
||||
낮은 결합도 (🟢): 21개 (72.5%)
|
||||
└── 나머지 모든 컴포넌트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 장애 영향 분석
|
||||
|
||||
### 7.1 현재 구조에서의 장애 전파 경로
|
||||
|
||||
```
|
||||
v2-button-primary 오류 발생 시:
|
||||
├── buttonActions.ts 영향 → 모든 저장/삭제 기능 중단
|
||||
├── refreshTable 이벤트 미발생 → 테이블 갱신 안됨
|
||||
└── closeEditModal 이벤트 미발생 → 모달 닫기 안됨
|
||||
|
||||
v2-table-list 오류 발생 시:
|
||||
├── tableListDataChange 미발생 → 집계 위젯 업데이트 안됨
|
||||
├── related-button 이벤트 미발생 → 관련 버튼 비활성화
|
||||
└── 전역 상태 오염 가능성
|
||||
|
||||
UnifiedRepeater 오류 발생 시:
|
||||
├── beforeFormSave 처리 실패 → 리피터 데이터 저장 누락
|
||||
├── repeaterSave 수신 실패 → 저장 요청 무시
|
||||
└── 전역 인스턴스 레지스트리 오류
|
||||
```
|
||||
|
||||
### 7.2 장애 격리 현황
|
||||
|
||||
| 컴포넌트 | 장애 시 영향 범위 | 격리 수준 |
|
||||
|---------|-----------------|----------|
|
||||
| v2-button-primary | 저장/삭제 전체 | ❌ 격리 안됨 |
|
||||
| v2-table-list | 집계/관련버튼 | ❌ 격리 안됨 |
|
||||
| UnifiedRepeater | 리피터 저장 | ❌ 격리 안됨 |
|
||||
| v2-aggregation-widget | 자신만 | ✅ 부분 격리 |
|
||||
| v2-repeat-container | 자신만 | ✅ 부분 격리 |
|
||||
| 나머지 21개 | 자신만 | ✅ 완전 격리 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 느슨한 결합 전환 권장사항
|
||||
|
||||
### 8.1 1단계: 인프라 구축 (1-2일)
|
||||
|
||||
1. **V2 EventBus 생성**
|
||||
- 타입 안전한 이벤트 시스템
|
||||
- 에러 격리 (Promise.allSettled)
|
||||
- 구독/발행 패턴
|
||||
|
||||
2. **V2 ErrorBoundary 생성**
|
||||
- 컴포넌트별 장애 격리
|
||||
- 폴백 UI 제공
|
||||
- 재시도 기능
|
||||
|
||||
### 8.2 2단계: 핵심 컴포넌트 분리 (3-4일)
|
||||
|
||||
| 우선순위 | 컴포넌트 | 작업 내용 |
|
||||
|---------|---------|----------|
|
||||
| 1 | v2-button-primary | buttonActions 의존성 제거, 독립 저장 서비스 |
|
||||
| 2 | v2-table-list | 전역 상태 제거, EventBus 전환 |
|
||||
| 3 | UnifiedRepeater | 전역 상태 제거, EventBus 전환 |
|
||||
|
||||
### 8.3 3단계: 이벤트 통합 (2-3일)
|
||||
|
||||
| 기존 이벤트 | 신규 이벤트 | 변환 방식 |
|
||||
|------------|------------|----------|
|
||||
| `refreshTable` | `v2:table:refresh` | EventBus 발행 |
|
||||
| `beforeFormSave` | `v2:form:save:before` | EventBus 발행 |
|
||||
| `tableListDataChange` | `v2:table:data:change` | EventBus 발행 |
|
||||
| `repeaterSave` | `v2:repeater:save` | EventBus 발행 |
|
||||
|
||||
### 8.4 4단계: 레거시 제거 (1-2일)
|
||||
|
||||
- `window.__` 전역 변수 → Context API 또는 Zustand
|
||||
- 기존 CustomEvent → V2 EventBus로 완전 전환
|
||||
- buttonActions.ts 경량화 (7,145줄 → 분할)
|
||||
|
||||
---
|
||||
|
||||
## 9. 예상 효과
|
||||
|
||||
### 9.1 장애 격리
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| 한 컴포넌트 오류 → 연쇄 실패 | 한 컴포넌트 오류 → 해당만 실패 표시 |
|
||||
| 저장 실패 → 전체 중단 | 저장 실패 → 부분 저장 + 에러 표시 |
|
||||
|
||||
### 9.2 유지보수성
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| buttonActions.ts 7,145줄 | 여러 서비스로 분리 (각 500줄 이하) |
|
||||
| 암묵적 이벤트 계약 | 타입 정의된 이벤트 |
|
||||
| 전역 상태 오염 위험 | Context/Store로 관리 |
|
||||
|
||||
### 9.3 테스트 용이성
|
||||
|
||||
| 현재 | 전환 후 |
|
||||
|------|--------|
|
||||
| 통합 테스트만 가능 | 단위 테스트 가능 |
|
||||
| 모킹 어려움 | EventBus 모킹 용이 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 구현 현황 (2026-01-26 업데이트)
|
||||
|
||||
### 10.1 V2 Core 인프라 (✅ 완료)
|
||||
|
||||
다음 핵심 인프라가 구현되었습니다:
|
||||
|
||||
| 모듈 | 경로 | 설명 | 상태 |
|
||||
|------|------|------|------|
|
||||
| **V2 EventBus** | `lib/v2-core/events/EventBus.ts` | 타입 안전한 이벤트 시스템 | ✅ 완료 |
|
||||
| **V2 이벤트 타입** | `lib/v2-core/events/types.ts` | 모든 이벤트 타입 정의 | ✅ 완료 |
|
||||
| **V2 ErrorBoundary** | `lib/v2-core/components/V2ErrorBoundary.tsx` | 컴포넌트별 에러 격리 | ✅ 완료 |
|
||||
| **레거시 어댑터** | `lib/v2-core/adapters/LegacyEventAdapter.ts` | CustomEvent ↔ EventBus 브릿지 | ✅ 완료 |
|
||||
| **V2 Core 초기화** | `lib/v2-core/init.ts` | 앱 시작 시 초기화 | ✅ 완료 |
|
||||
|
||||
### 10.2 컴포넌트 마이그레이션 현황
|
||||
|
||||
| 컴포넌트 | V2 EventBus 적용 | ErrorBoundary 적용 | 레거시 지원 | 상태 |
|
||||
|---------|-----------------|-------------------|-------------|------|
|
||||
| **v2-button-primary** | ✅ | ✅ | ✅ | 완료 |
|
||||
| **v2-table-list** | ✅ | - | ✅ | 완료 |
|
||||
| **UnifiedRepeater** | ✅ | - | ✅ | 완료 |
|
||||
|
||||
### 10.3 아키텍처 특징
|
||||
|
||||
**점진적 마이그레이션 지원:**
|
||||
- 레거시 `window.dispatchEvent` 이벤트와 V2 EventBus 이벤트가 **양방향 브릿지**로 연결됨
|
||||
- 기존 코드 수정 없이 새 시스템 도입 가능
|
||||
- 모든 V2 이벤트는 자동으로 레거시 CustomEvent로도 발행됨
|
||||
|
||||
**에러 격리:**
|
||||
- V2ErrorBoundary로 감싼 컴포넌트는 에러 발생 시 해당 컴포넌트만 에러 UI 표시
|
||||
- 다른 컴포넌트는 정상 작동 유지
|
||||
- 재시도 버튼으로 복구 가능
|
||||
|
||||
### 10.4 사용 방법
|
||||
|
||||
```typescript
|
||||
// 이벤트 발행
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||||
tableName: "item_info",
|
||||
target: "single",
|
||||
});
|
||||
|
||||
// 이벤트 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
console.log("테이블 새로고침:", payload.tableName);
|
||||
},
|
||||
{ componentId: "my-component" }
|
||||
);
|
||||
|
||||
// 정리
|
||||
useEffect(() => {
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 결론
|
||||
|
||||
### 11.1 현재 상태 요약
|
||||
|
||||
- **전체 29개 컴포넌트 중 72.5%(21개)는 이미 낮은 결합도**를 가지고 있어 독립적으로 동작
|
||||
- **핵심 문제 컴포넌트 3개 (v2-button-primary, v2-table-list, UnifiedRepeater) 마이그레이션 완료**
|
||||
- **buttonActions.ts (7,145줄)**는 추후 분할 예정 (현재는 동작 유지)
|
||||
|
||||
### 11.2 달성 목표
|
||||
|
||||
✅ **V2 Core 인프라 구축 완료**
|
||||
|
||||
- 타입 안전한 EventBus
|
||||
- 컴포넌트별 ErrorBoundary
|
||||
- 레거시 호환 어댑터
|
||||
- 앱 초기화 연동
|
||||
|
||||
### 11.3 다음 단계
|
||||
|
||||
1. **buttonActions.ts 분할** - 서비스별 모듈 분리
|
||||
2. **나머지 중간 결합도 컴포넌트 마이그레이션** (v2-repeat-container, v2-split-panel-layout 등)
|
||||
3. **전역 상태 (window.__) 제거** - Context API 또는 Zustand로 전환
|
||||
|
||||
---
|
||||
|
||||
## 부록 A: 파일 위치 참조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── lib/
|
||||
│ ├── registry/
|
||||
│ │ └── components/
|
||||
│ │ ├── v2-aggregation-widget/
|
||||
│ │ ├── v2-button-primary/
|
||||
│ │ ├── v2-card-display/
|
||||
│ │ ├── v2-category-manager/
|
||||
│ │ ├── v2-divider-line/
|
||||
│ │ ├── v2-location-swap-selector/
|
||||
│ │ ├── v2-numbering-rule/
|
||||
│ │ ├── v2-pivot-grid/
|
||||
│ │ ├── v2-rack-structure/
|
||||
│ │ ├── v2-repeat-container/
|
||||
│ │ ├── v2-repeat-screen-modal/
|
||||
│ │ ├── v2-section-card/
|
||||
│ │ ├── v2-section-paper/
|
||||
│ │ ├── v2-split-panel-layout/
|
||||
│ │ ├── v2-table-list/
|
||||
│ │ ├── v2-table-search-widget/
|
||||
│ │ ├── v2-tabs-widget/
|
||||
│ │ ├── v2-text-display/
|
||||
│ │ └── v2-unified-repeater/
|
||||
│ └── utils/
|
||||
│ └── buttonActions.ts (7,145줄)
|
||||
└── components/
|
||||
└── unified/
|
||||
├── UnifiedInput.tsx
|
||||
├── UnifiedSelect.tsx
|
||||
├── UnifiedDate.tsx
|
||||
├── UnifiedRepeater.tsx
|
||||
├── UnifiedLayout.tsx
|
||||
├── UnifiedGroup.tsx
|
||||
├── UnifiedHierarchy.tsx
|
||||
├── UnifiedList.tsx
|
||||
├── UnifiedMedia.tsx
|
||||
├── UnifiedBiz.tsx
|
||||
└── UnifiedFormContext.tsx
|
||||
```
|
||||
|
||||
## 부록 B: V2 Core 파일 구조 (구현됨)
|
||||
|
||||
```
|
||||
frontend/lib/v2-core/
|
||||
├── index.ts # 메인 내보내기
|
||||
├── init.ts # 앱 초기화
|
||||
├── events/
|
||||
│ ├── index.ts
|
||||
│ ├── types.ts # 이벤트 타입 정의
|
||||
│ └── EventBus.ts # 이벤트 버스 구현
|
||||
├── components/
|
||||
│ ├── index.ts
|
||||
│ └── V2ErrorBoundary.tsx # 에러 바운더리
|
||||
└── adapters/
|
||||
├── index.ts
|
||||
└── LegacyEventAdapter.ts # 레거시 브릿지
|
||||
```
|
||||
|
||||
## 부록 C: 이벤트 타입 정의 (구현됨)
|
||||
|
||||
전체 이벤트 타입은 `frontend/lib/v2-core/events/types.ts`에 정의되어 있습니다.
|
||||
|
||||
주요 이벤트:
|
||||
|
||||
| 이벤트 | 설명 |
|
||||
|--------|------|
|
||||
| `v2:table:refresh` | 테이블 새로고침 |
|
||||
| `v2:table:data:change` | 테이블 데이터 변경 |
|
||||
| `v2:form:save:collect` | 폼 저장 전 데이터 수집 |
|
||||
| `v2:modal:close` | 모달 닫기 |
|
||||
| `v2:modal:save:success` | 모달 저장 성공 |
|
||||
| `v2:repeater:save` | 리피터 저장 |
|
||||
| `v2:component:error` | 컴포넌트 에러 |
|
||||
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
# V2 컴포넌트 가이드
|
||||
|
||||
> 작성일: 2026-01-26
|
||||
> 목적: V2 컴포넌트 전반적인 아키텍처, 설계 원칙, 사용법 정리
|
||||
|
||||
---
|
||||
|
||||
## 1. V2 컴포넌트 개요
|
||||
|
||||
### 1.1 V2란?
|
||||
|
||||
V2(Version 2) 컴포넌트는 기존 레거시 컴포넌트의 문제점을 해결하고 다음 목표를 달성하기 위해 재설계된 컴포넌트입니다:
|
||||
|
||||
- **느슨한 결합 (Loose Coupling)**: 컴포넌트 간 직접 의존성 제거
|
||||
- **장애 격리 (Fault Isolation)**: 한 컴포넌트 오류가 다른 컴포넌트에 영향 없음
|
||||
- **화면 복제 용이성**: 메뉴/회사 종속적인 설정 제거
|
||||
- **점진적 마이그레이션**: 레거시 컴포넌트와 공존 가능
|
||||
|
||||
### 1.2 V2 vs 레거시 비교
|
||||
|
||||
| 항목 | 레거시 | V2 |
|
||||
|------|--------|-----|
|
||||
| 이벤트 통신 | `window.dispatchEvent` | V2 EventBus |
|
||||
| 에러 처리 | 전역 오류 → 전체 중단 | ErrorBoundary → 해당만 실패 |
|
||||
| 전역 상태 | `window.__xxx` | Context/Store |
|
||||
| 채번/카테고리 | 메뉴에 종속 | 테이블 컬럼에 종속 |
|
||||
| 설정 저장 | componentConfig에 ID 저장 | 표시 옵션만 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 2. V2 컴포넌트 목록 (19개)
|
||||
|
||||
### 2.1 레이아웃 컴포넌트
|
||||
|
||||
| 컴포넌트 ID | 이름 | 설명 |
|
||||
|------------|------|------|
|
||||
| `v2-split-panel-layout` | 분할 패널 레이아웃 | 좌우/상하 분할 레이아웃 |
|
||||
| `v2-section-card` | 섹션 카드 | 카드 형태 컨테이너 |
|
||||
| `v2-section-paper` | 섹션 페이퍼 | 페이퍼 형태 컨테이너 |
|
||||
| `v2-tabs-widget` | 탭 위젯 | 탭 기반 컨테이너 |
|
||||
| `v2-repeat-container` | 리피터 컨테이너 | 반복 섹션 컨테이너 |
|
||||
| `v2-divider-line` | 구분선 | 시각적 구분선 |
|
||||
|
||||
### 2.2 데이터 표시 컴포넌트
|
||||
|
||||
| 컴포넌트 ID | 이름 | 설명 |
|
||||
|------------|------|------|
|
||||
| `v2-table-list` | 테이블 리스트 | 데이터 그리드/테이블 |
|
||||
| `v2-card-display` | 카드 디스플레이 | 카드 형태 데이터 표시 |
|
||||
| `v2-text-display` | 텍스트 디스플레이 | 텍스트 표시 |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | 피벗 테이블 |
|
||||
| `v2-aggregation-widget` | 집계 위젯 | 데이터 집계 표시 |
|
||||
|
||||
### 2.3 입력/관리 컴포넌트
|
||||
|
||||
| 컴포넌트 ID | 이름 | 설명 |
|
||||
|------------|------|------|
|
||||
| `v2-button-primary` | 기본 버튼 | 저장/삭제/모달 등 액션 버튼 |
|
||||
| `v2-numbering-rule` | 채번 규칙 | 채번 규칙 설정 컴포넌트 |
|
||||
| `v2-category-manager` | 카테고리 관리 | 트리 기반 카테고리 관리 |
|
||||
| `v2-table-search-widget` | 테이블 검색 위젯 | 테이블 검색 UI |
|
||||
| `v2-location-swap-selector` | 위치 교환 선택기 | 출발지/도착지 선택 |
|
||||
|
||||
### 2.4 특수 컴포넌트
|
||||
|
||||
| 컴포넌트 ID | 이름 | 설명 |
|
||||
|------------|------|------|
|
||||
| `v2-rack-structure` | 렉 구조 | 창고 렉 구조 표시 |
|
||||
| `v2-repeat-screen-modal` | 반복 화면 모달 | 반복 가능한 화면 모달 |
|
||||
| `v2-unified-repeater` | 통합 리피터 | 통합 리피터 테이블 |
|
||||
|
||||
---
|
||||
|
||||
## 3. V2 Core 인프라
|
||||
|
||||
### 3.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/lib/v2-core/
|
||||
├── index.ts # 메인 내보내기
|
||||
├── init.ts # 앱 초기화
|
||||
├── events/
|
||||
│ ├── index.ts
|
||||
│ ├── types.ts # 이벤트 타입 정의 (30+)
|
||||
│ └── EventBus.ts # 타입 안전한 이벤트 버스
|
||||
├── components/
|
||||
│ ├── index.ts
|
||||
│ └── V2ErrorBoundary.tsx # 에러 바운더리
|
||||
└── adapters/
|
||||
├── index.ts
|
||||
└── LegacyEventAdapter.ts # 레거시 브릿지
|
||||
```
|
||||
|
||||
### 3.2 V2 EventBus
|
||||
|
||||
타입 안전한 Pub/Sub 이벤트 시스템입니다.
|
||||
|
||||
**특징:**
|
||||
- 타입 안전한 이벤트 발행/구독
|
||||
- 에러 격리 (Promise.allSettled)
|
||||
- 타임아웃 및 재시도 지원
|
||||
- 디버그 모드 지원
|
||||
|
||||
**사용법:**
|
||||
|
||||
```typescript
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
// 이벤트 발행
|
||||
v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||||
tableName: "item_info",
|
||||
target: "single",
|
||||
});
|
||||
|
||||
// 이벤트 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.TABLE_REFRESH,
|
||||
(payload) => {
|
||||
console.log("테이블 새로고침:", payload.tableName);
|
||||
},
|
||||
{ componentId: "my-component" }
|
||||
);
|
||||
|
||||
// 정리 (useEffect cleanup에서)
|
||||
useEffect(() => {
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 3.3 V2 ErrorBoundary
|
||||
|
||||
컴포넌트별 에러 격리를 제공합니다.
|
||||
|
||||
**특징:**
|
||||
- 에러 발생 시 해당 컴포넌트만 폴백 UI 표시
|
||||
- 3가지 폴백 스타일 (minimal, compact, full)
|
||||
- 재시도 기능
|
||||
- 에러 이벤트 자동 발행
|
||||
|
||||
**사용법:**
|
||||
|
||||
```tsx
|
||||
import { V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
// 컴포넌트 래핑
|
||||
<V2ErrorBoundary
|
||||
componentId="my-component-id"
|
||||
componentType="MyComponent"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<MyComponent {...props} />
|
||||
</V2ErrorBoundary>
|
||||
```
|
||||
|
||||
### 3.4 Legacy Event Adapter
|
||||
|
||||
기존 CustomEvent와 V2 EventBus 간 양방향 브릿지입니다.
|
||||
|
||||
**특징:**
|
||||
- 레거시 `window.dispatchEvent` → V2 EventBus 자동 변환
|
||||
- V2 EventBus → 레거시 CustomEvent 자동 변환
|
||||
- 무한 루프 방지
|
||||
- 점진적 마이그레이션 지원
|
||||
|
||||
---
|
||||
|
||||
## 4. 이벤트 시스템
|
||||
|
||||
### 4.1 주요 이벤트 목록
|
||||
|
||||
| 이벤트 | 설명 | 발행자 | 구독자 |
|
||||
|--------|------|--------|--------|
|
||||
| `v2:table:refresh` | 테이블 새로고침 | v2-button-primary | v2-table-list |
|
||||
| `v2:table:data:change` | 테이블 데이터 변경 | v2-table-list | v2-aggregation-widget |
|
||||
| `v2:form:save:collect` | 폼 저장 전 데이터 수집 | buttonActions | v2-repeat-container, UnifiedRepeater |
|
||||
| `v2:modal:close` | 모달 닫기 | v2-button-primary | EditModal |
|
||||
| `v2:modal:save:success` | 모달 저장 성공 | v2-button-primary | EditModal |
|
||||
| `v2:repeater:save` | 리피터 저장 | buttonActions | UnifiedRepeater |
|
||||
| `v2:component:error` | 컴포넌트 에러 | V2ErrorBoundary | 로깅/모니터링 |
|
||||
|
||||
### 4.2 이벤트 흐름 다이어그램
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ V2 EventBus (중앙) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │TABLE_REFRESH│ │TABLE_DATA │ │FORM_SAVE │ │
|
||||
│ │ │ │ _CHANGE │ │ _COLLECT │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
└──────────│──────────────────│──────────────────│────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||||
│ v2-table-list │ │v2-aggregation │ │v2-repeat │
|
||||
│ │ │ -widget │ │ -container │
|
||||
└───────────────┘ └───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 채번/카테고리 시스템
|
||||
|
||||
### 5.1 설계 원칙
|
||||
|
||||
**핵심: 메뉴 종속성 제거**
|
||||
|
||||
- ❌ 이전: 채번/카테고리 설정이 화면 레이아웃(componentConfig)에 저장
|
||||
- ✅ 현재: 채번/카테고리 설정이 테이블 컬럼 정의에 저장
|
||||
|
||||
### 5.2 채번 규칙 동작 방식
|
||||
|
||||
```
|
||||
1. 테이블 타입 관리에서 컬럼에 input_type='numbering' 설정
|
||||
2. 해당 컬럼에 numbering_rule_id 연결 (테이블 정의에 저장)
|
||||
3. 화면에서 해당 컬럼 사용 시 자동으로 채번 규칙 적용
|
||||
4. 화면 복제해도 테이블 정의는 그대로 → 채번 규칙 유지
|
||||
```
|
||||
|
||||
**관련 테이블:**
|
||||
- `numbering_rules_test`: 채번 규칙 마스터
|
||||
- `numbering_rule_parts_test`: 채번 규칙 파트
|
||||
- `column_labels`: 컬럼별 input_type 및 설정 저장
|
||||
|
||||
### 5.3 카테고리 동작 방식
|
||||
|
||||
```
|
||||
1. 테이블 타입 관리에서 컬럼에 input_type='category' 설정
|
||||
2. category_values_test 테이블에 카테고리 값 저장 (트리 구조)
|
||||
3. 화면에서 해당 컬럼 사용 시 자동으로 카테고리 드롭다운 표시
|
||||
4. 화면 복제해도 테이블 정의는 그대로 → 카테고리 유지
|
||||
```
|
||||
|
||||
**관련 테이블:**
|
||||
- `category_values_test`: 카테고리 값 (트리 구조, 3단계 지원)
|
||||
- `parent_id`: 부모 노드 ID
|
||||
- `level`: 깊이 (1=대분류, 2=중분류, 3=소분류)
|
||||
- `path`: 경로 (예: "1.2.3")
|
||||
|
||||
### 5.4 화면 복제 시 이점
|
||||
|
||||
```
|
||||
이전 (메뉴 종속):
|
||||
화면 복제 → 채번/카테고리 ID도 복제 → 잘못된 참조 → 수동 수정 필요
|
||||
|
||||
현재 (테이블 종속):
|
||||
화면 복제 → 테이블 컬럼 정의 참조 → 자동으로 올바른 채번/카테고리 적용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 패널 (ConfigPanel) 가이드
|
||||
|
||||
### 6.1 설계 원칙
|
||||
|
||||
V2 컴포넌트의 ConfigPanel은 **표시/동작 옵션만** 저장합니다.
|
||||
|
||||
**저장해야 하는 것:**
|
||||
- 뷰 모드 (tree/list/card 등)
|
||||
- 레이아웃 설정 (너비, 높이, 패딩)
|
||||
- 표시 옵션 (readonly, showPreview 등)
|
||||
- 스타일 설정 (색상, 폰트 등)
|
||||
|
||||
**저장하면 안 되는 것:**
|
||||
- ❌ 특정 채번 규칙 ID (numberingRuleId)
|
||||
- ❌ 특정 카테고리 ID (categoryId)
|
||||
- ❌ 메뉴 ID (menuObjid, menu_id)
|
||||
- ❌ 회사 코드 (companyCode) - 런타임에 결정
|
||||
|
||||
### 6.2 ConfigPanel 예시
|
||||
|
||||
```tsx
|
||||
// ✅ 올바른 ConfigPanel
|
||||
export const MyComponentConfigPanel: React.FC<Props> = ({ config, onChange }) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 표시 옵션만 설정 */}
|
||||
<div>
|
||||
<Label>뷰 모드</Label>
|
||||
<Select
|
||||
value={config.viewMode}
|
||||
onValueChange={(v) => onChange({ ...config, viewMode: v })}
|
||||
>
|
||||
<SelectItem value="list">리스트</SelectItem>
|
||||
<SelectItem value="card">카드</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>읽기 전용</Label>
|
||||
<Switch
|
||||
checked={config.readonly}
|
||||
onCheckedChange={(v) => onChange({ ...config, readonly: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ 잘못된 ConfigPanel
|
||||
export const BadConfigPanel: React.FC<Props> = ({ config, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
{/* 채번 규칙 ID를 저장하면 안 됨! */}
|
||||
<Select
|
||||
value={config.numberingRuleId} // ❌
|
||||
onValueChange={(v) => onChange({ ...config, numberingRuleId: v })}
|
||||
>
|
||||
{numberingRules.map(rule => (
|
||||
<SelectItem value={rule.id}>{rule.name}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 결합도 현황
|
||||
|
||||
### 7.1 V2 컴포넌트 결합도 점수
|
||||
|
||||
| 결합도 수준 | 개수 | 컴포넌트 |
|
||||
|------------|------|---------|
|
||||
| 🔴 높음 (7-10점) | 0개 | - (마이그레이션 완료) |
|
||||
| 🟠 중간 (4-6점) | 4개 | v2-repeat-container, v2-split-panel-layout, v2-aggregation-widget, v2-tabs-widget |
|
||||
| 🟢 낮음 (1-3점) | 15개 | 나머지 모든 V2 컴포넌트 |
|
||||
|
||||
### 7.2 마이그레이션 완료 컴포넌트
|
||||
|
||||
| 컴포넌트 | V2 EventBus | ErrorBoundary | 레거시 호환 |
|
||||
|---------|-------------|---------------|-------------|
|
||||
| v2-button-primary | ✅ | ✅ | ✅ |
|
||||
| v2-table-list | ✅ | ✅ | ✅ |
|
||||
| UnifiedRepeater | ✅ | ✅ | ✅ |
|
||||
|
||||
### 7.3 장애 격리 검증
|
||||
|
||||
```
|
||||
v2-button-primary 에러 발생 시:
|
||||
├── V2ErrorBoundary 캐치 → 버튼만 에러 UI 표시
|
||||
├── v2-table-list: 정상 동작 ✅
|
||||
└── UnifiedRepeater: 정상 동작 ✅
|
||||
|
||||
v2-table-list 에러 발생 시:
|
||||
├── V2ErrorBoundary 캐치 → 테이블만 에러 UI 표시
|
||||
├── v2-button-primary: 정상 동작 ✅
|
||||
└── v2-aggregation-widget: 데이터 없음 상태 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Unified 폼 컴포넌트
|
||||
|
||||
### 8.1 목록 (11개)
|
||||
|
||||
| 컴포넌트 | 파일 | 용도 |
|
||||
|---------|------|------|
|
||||
| UnifiedInput | UnifiedInput.tsx | 텍스트/숫자/이메일/채번 입력 |
|
||||
| UnifiedSelect | UnifiedSelect.tsx | 선택박스/라디오/체크박스/카테고리 |
|
||||
| UnifiedDate | UnifiedDate.tsx | 날짜/시간 입력 |
|
||||
| UnifiedRepeater | UnifiedRepeater.tsx | 리피터 테이블 |
|
||||
| UnifiedLayout | UnifiedLayout.tsx | 레이아웃 컨테이너 |
|
||||
| UnifiedGroup | UnifiedGroup.tsx | 그룹 컨테이너 |
|
||||
| UnifiedHierarchy | UnifiedHierarchy.tsx | 계층 구조 표시 |
|
||||
| UnifiedList | UnifiedList.tsx | 리스트 표시 |
|
||||
| UnifiedMedia | UnifiedMedia.tsx | 파일/이미지/비디오 |
|
||||
| UnifiedBiz | UnifiedBiz.tsx | 비즈니스 컴포넌트 |
|
||||
| UnifiedFormContext | UnifiedFormContext.tsx | 폼 상태 관리 |
|
||||
|
||||
### 8.2 inputType 자동 처리
|
||||
|
||||
Unified 컴포넌트는 `inputType`에 따라 자동으로 적절한 UI를 렌더링합니다:
|
||||
|
||||
```typescript
|
||||
// UnifiedInput.tsx
|
||||
switch (inputType) {
|
||||
case "numbering":
|
||||
// 채번 규칙 자동 조회 및 코드 생성
|
||||
break;
|
||||
case "text":
|
||||
case "email":
|
||||
case "phone":
|
||||
// 텍스트 입력
|
||||
break;
|
||||
}
|
||||
|
||||
// UnifiedSelect.tsx
|
||||
switch (inputType) {
|
||||
case "category":
|
||||
// 카테고리 값 자동 조회 및 드롭다운 표시
|
||||
break;
|
||||
case "select":
|
||||
case "radio":
|
||||
// 일반 선택
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 개발 가이드
|
||||
|
||||
### 9.1 새 V2 컴포넌트 생성
|
||||
|
||||
```bash
|
||||
frontend/lib/registry/components/v2-my-component/
|
||||
├── index.ts # 컴포넌트 정의 (createComponentDefinition)
|
||||
├── types.ts # 타입 정의
|
||||
├── MyComponent.tsx # 메인 컴포넌트
|
||||
├── MyComponentRenderer.tsx # 렌더러 (선택)
|
||||
├── MyComponentConfigPanel.tsx # 설정 패널
|
||||
└── README.md # 문서
|
||||
```
|
||||
|
||||
### 9.2 컴포넌트 정의 템플릿
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
import { createComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { MyComponent } from "./MyComponent";
|
||||
import { MyComponentConfigPanel } from "./MyComponentConfigPanel";
|
||||
import { defaultConfig } from "./types";
|
||||
|
||||
export const V2MyComponentDefinition = createComponentDefinition({
|
||||
id: "v2-my-component",
|
||||
name: "내 컴포넌트",
|
||||
nameEng: "My Component",
|
||||
description: "컴포넌트 설명",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
component: MyComponent,
|
||||
defaultConfig,
|
||||
configPanel: MyComponentConfigPanel,
|
||||
tags: ["태그1", "태그2"],
|
||||
});
|
||||
```
|
||||
|
||||
### 9.3 V2 EventBus 사용 체크리스트
|
||||
|
||||
- [ ] V2 EventBus import: `import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";`
|
||||
- [ ] 이벤트 구독 시 `componentId` 설정
|
||||
- [ ] `useEffect` cleanup에서 `unsubscribe()` 호출
|
||||
- [ ] 레거시 호환 필요 시 `window.addEventListener`도 유지 (점진적 마이그레이션)
|
||||
|
||||
### 9.4 V2 ErrorBoundary 사용 체크리스트
|
||||
|
||||
- [ ] 컴포넌트 export에서 ErrorBoundary 래핑
|
||||
- [ ] `componentId`와 `componentType` 설정
|
||||
- [ ] 적절한 `fallbackStyle` 선택
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
### 10.1 관련 문서
|
||||
|
||||
- [V2 컴포넌트 결합도 분석](./V2_COMPONENT_COUPLING_ANALYSIS.md)
|
||||
- [채번 규칙 가이드](../frontend/lib/registry/components/v2-numbering-rule/README.md)
|
||||
- [카테고리 트리 구조](../db/migrations/042_create_category_values_test.sql)
|
||||
|
||||
### 10.2 관련 파일
|
||||
|
||||
```
|
||||
V2 Core:
|
||||
- frontend/lib/v2-core/
|
||||
|
||||
V2 컴포넌트:
|
||||
- frontend/lib/registry/components/v2-*/
|
||||
|
||||
Unified 폼 컴포넌트:
|
||||
- frontend/components/unified/
|
||||
|
||||
채번/카테고리 테스트 테이블:
|
||||
- db/migrations/040_create_numbering_rules_test.sql
|
||||
- db/migrations/042_create_category_values_test.sql
|
||||
```
|
||||
|
||||
### 10.3 디버깅
|
||||
|
||||
개발 환경에서 다음 전역 객체로 상태 확인 가능:
|
||||
|
||||
```javascript
|
||||
// 브라우저 콘솔에서
|
||||
window.__v2EventBus.printState() // EventBus 구독 상태
|
||||
window.__legacyEventAdapter.getMappings() // 레거시 이벤트 매핑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 향후 계획
|
||||
|
||||
### 11.1 단기 (1-2주)
|
||||
|
||||
- [ ] 나머지 중간 결합도 컴포넌트 마이그레이션
|
||||
- v2-repeat-container
|
||||
- v2-split-panel-layout
|
||||
- v2-aggregation-widget
|
||||
- v2-tabs-widget
|
||||
|
||||
### 11.2 중기 (1개월)
|
||||
|
||||
- [ ] buttonActions.ts 분할 (7,145줄 → 여러 서비스)
|
||||
- [ ] 전역 상태 (`window.__`) 제거
|
||||
- [ ] Zustand/Context로 상태 관리 전환
|
||||
|
||||
### 11.3 장기
|
||||
|
||||
- [ ] 레거시 컴포넌트 완전 제거
|
||||
- [ ] CustomEvent 완전 제거
|
||||
- [ ] V2 전용 모드 도입
|
||||
|
||||
---
|
||||
|
||||
## 부록: V2 컴포넌트 위치
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/
|
||||
├── v2-aggregation-widget/
|
||||
├── v2-button-primary/
|
||||
├── v2-card-display/
|
||||
├── v2-category-manager/
|
||||
├── v2-divider-line/
|
||||
├── v2-location-swap-selector/
|
||||
├── v2-numbering-rule/
|
||||
├── v2-pivot-grid/
|
||||
├── v2-rack-structure/
|
||||
├── v2-repeat-container/
|
||||
├── v2-repeat-screen-modal/
|
||||
├── v2-section-card/
|
||||
├── v2-section-paper/
|
||||
├── v2-split-panel-layout/
|
||||
├── v2-table-list/
|
||||
├── v2-table-search-widget/
|
||||
├── v2-tabs-widget/
|
||||
├── v2-text-display/
|
||||
└── v2-unified-repeater/
|
||||
```
|
||||
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# Phase 0: 컴포넌트 사용 현황 분석
|
||||
|
||||
## 분석 일시
|
||||
|
||||
2024-12-19
|
||||
|
||||
## 분석 대상
|
||||
|
||||
- 활성화된 화면 정의 (screen_definitions.is_active = 'Y')
|
||||
- 화면 레이아웃 (screen_layouts)
|
||||
|
||||
---
|
||||
|
||||
## 1. 컴포넌트별 사용량 순위
|
||||
|
||||
### 상위 15개 컴포넌트
|
||||
|
||||
| 순위 | 컴포넌트 | 사용 횟수 | 사용 화면 수 | Unified 매핑 |
|
||||
| :--: | :-------------------------- | :-------: | :----------: | :------------------------------ |
|
||||
| 1 | button-primary | 571 | 364 | UnifiedInput (type: button) |
|
||||
| 2 | text-input | 805 | 166 | **UnifiedInput (type: text)** |
|
||||
| 3 | table-list | 130 | 130 | UnifiedList (viewMode: table) |
|
||||
| 4 | table-search-widget | 127 | 127 | UnifiedList (searchable: true) |
|
||||
| 5 | select-basic | 121 | 76 | **UnifiedSelect** |
|
||||
| 6 | number-input | 86 | 34 | **UnifiedInput (type: number)** |
|
||||
| 7 | date-input | 83 | 51 | **UnifiedDate** |
|
||||
| 8 | file-upload | 41 | 18 | UnifiedMedia (type: file) |
|
||||
| 9 | tabs-widget | 39 | 39 | UnifiedGroup (type: tabs) |
|
||||
| 10 | split-panel-layout | 39 | 39 | UnifiedLayout (type: split) |
|
||||
| 11 | category-manager | 38 | 38 | UnifiedBiz (type: category) |
|
||||
| 12 | numbering-rule | 31 | 31 | UnifiedBiz (type: numbering) |
|
||||
| 13 | selected-items-detail-input | 29 | 29 | 복합 컴포넌트 |
|
||||
| 14 | modal-repeater-table | 25 | 25 | UnifiedList (modal: true) |
|
||||
| 15 | image-widget | 29 | 29 | UnifiedMedia (type: image) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Unified 컴포넌트별 통합 대상 분석
|
||||
|
||||
### UnifiedInput (예상 통합 대상: 891개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 | 비율 |
|
||||
| :------------ | :-------: | :---: |
|
||||
| text-input | 805 | 90.3% |
|
||||
| number-input | 86 | 9.7% |
|
||||
|
||||
**우선순위: 1위** - 가장 많이 사용되는 컴포넌트
|
||||
|
||||
### UnifiedSelect (예상 통합 대상: 140개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 | widgetType |
|
||||
| :------------------------ | :-------: | :--------- |
|
||||
| select-basic (category) | 65 | category |
|
||||
| select-basic (null) | 50 | - |
|
||||
| autocomplete-search-input | 19 | entity |
|
||||
| entity-search-input | 20 | entity |
|
||||
| checkbox-basic | 7 | checkbox |
|
||||
| radio-basic | 5 | radio |
|
||||
|
||||
**우선순위: 2위** - 다양한 모드 지원 필요
|
||||
|
||||
### UnifiedDate (예상 통합 대상: 83개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 |
|
||||
| :---------------- | :-------: |
|
||||
| date-input (null) | 58 |
|
||||
| date-input (date) | 23 |
|
||||
| date-input (text) | 2 |
|
||||
|
||||
**우선순위: 3위**
|
||||
|
||||
### UnifiedList (예상 통합 대상: 283개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 | 비고 |
|
||||
| :-------------------- | :-------: | :---------- |
|
||||
| table-list | 130 | 기본 테이블 |
|
||||
| table-search-widget | 127 | 검색 테이블 |
|
||||
| modal-repeater-table | 25 | 모달 반복 |
|
||||
| repeater-field-group | 15 | 반복 필드 |
|
||||
| card-display | 11 | 카드 표시 |
|
||||
| simple-repeater-table | 1 | 단순 반복 |
|
||||
|
||||
**우선순위: 4위** - 핵심 데이터 표시 컴포넌트
|
||||
|
||||
### UnifiedMedia (예상 통합 대상: 70개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 |
|
||||
| :------------ | :-------: |
|
||||
| file-upload | 41 |
|
||||
| image-widget | 29 |
|
||||
|
||||
### UnifiedLayout (예상 통합 대상: 62개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 |
|
||||
| :------------------ | :-------: |
|
||||
| split-panel-layout | 39 |
|
||||
| screen-split-panel | 21 |
|
||||
| split-panel-layout2 | 2 |
|
||||
|
||||
### UnifiedGroup (예상 통합 대상: 99개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 |
|
||||
| :-------------------- | :-------: |
|
||||
| tabs-widget | 39 |
|
||||
| conditional-container | 23 |
|
||||
| section-paper | 11 |
|
||||
| section-card | 10 |
|
||||
| text-display | 13 |
|
||||
| universal-form-modal | 7 |
|
||||
| repeat-screen-modal | 5 |
|
||||
|
||||
### UnifiedBiz (예상 통합 대상: 79개)
|
||||
|
||||
| 기존 컴포넌트 | 사용 횟수 |
|
||||
| :--------------------- | :-------: |
|
||||
| category-manager | 38 |
|
||||
| numbering-rule | 31 |
|
||||
| flow-widget | 8 |
|
||||
| rack-structure | 2 |
|
||||
| related-data-buttons | 2 |
|
||||
| location-swap-selector | 2 |
|
||||
| tax-invoice-list | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 우선순위 결정
|
||||
|
||||
### Phase 1 우선순위 (즉시 효과가 큰 컴포넌트)
|
||||
|
||||
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 영향 화면 수 | 이유 |
|
||||
| :---: | :---------------- | :----------: | :----------: | :--------------- |
|
||||
| **1** | **UnifiedInput** | 891개 | 200+ | 가장 많이 사용 |
|
||||
| **2** | **UnifiedSelect** | 140개 | 100+ | 다양한 모드 필요 |
|
||||
| **3** | **UnifiedDate** | 83개 | 51 | 비교적 단순 |
|
||||
|
||||
### Phase 2 우선순위 (데이터 표시 컴포넌트)
|
||||
|
||||
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
|
||||
| :---: | :---------------- | :----------: | :--------------- |
|
||||
| **4** | **UnifiedList** | 283개 | 핵심 데이터 표시 |
|
||||
| **5** | **UnifiedLayout** | 62개 | 레이아웃 구조 |
|
||||
| **6** | **UnifiedGroup** | 99개 | 콘텐츠 그룹화 |
|
||||
|
||||
### Phase 3 우선순위 (특수 컴포넌트)
|
||||
|
||||
| 순위 | Unified 컴포넌트 | 통합 대상 수 | 이유 |
|
||||
| :---: | :------------------- | :----------: | :------------ |
|
||||
| **7** | **UnifiedMedia** | 70개 | 파일/이미지 |
|
||||
| **8** | **UnifiedBiz** | 79개 | 비즈니스 특화 |
|
||||
| **9** | **UnifiedHierarchy** | 0개 | 신규 기능 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 주요 발견 사항
|
||||
|
||||
### 4.1 button-primary 분리 검토
|
||||
|
||||
- 사용량: 571개 (1위)
|
||||
- 현재 계획: UnifiedInput에 포함
|
||||
- **제안**: 별도 `UnifiedButton` 컴포넌트로 분리 검토
|
||||
- 버튼은 입력과 성격이 다름
|
||||
- 액션 타입, 스타일, 권한 등 복잡한 설정 필요
|
||||
|
||||
### 4.2 conditional-container 처리
|
||||
|
||||
- 사용량: 23개
|
||||
- 현재 계획: 공통 conditional 속성으로 통합
|
||||
- **확인 필요**: 기존 화면에서 어떻게 마이그레이션할지
|
||||
|
||||
### 4.3 category 관련 컴포넌트
|
||||
|
||||
- select-basic (category): 65개
|
||||
- category-manager: 38개
|
||||
- **총 103개**의 카테고리 관련 컴포넌트
|
||||
- 카테고리 시스템 통합 중요
|
||||
|
||||
---
|
||||
|
||||
## 5. 다음 단계
|
||||
|
||||
1. [ ] 데이터 마이그레이션 전략 설계 (Phase 0-2)
|
||||
2. [ ] sys_input_type JSON Schema 설계 (Phase 0-3)
|
||||
3. [ ] DynamicConfigPanel 프로토타입 (Phase 0-4)
|
||||
4. [ ] UnifiedInput 구현 시작 (Phase 1-1)
|
||||
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
# Phase 0: 데이터 마이그레이션 전략
|
||||
|
||||
## 1. 현재 데이터 구조 분석
|
||||
|
||||
### screen_layouts.properties 구조
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// 기본 정보
|
||||
"type": "component",
|
||||
"componentType": "text-input", // 기존 컴포넌트 타입
|
||||
|
||||
// 위치/크기
|
||||
"position": { "x": 68, "y": 80, "z": 1 },
|
||||
"size": { "width": 324, "height": 40 },
|
||||
|
||||
// 라벨 및 스타일
|
||||
"label": "품목코드",
|
||||
"style": {
|
||||
"labelColor": "#000000",
|
||||
"labelDisplay": true,
|
||||
"labelFontSize": "14px",
|
||||
"labelFontWeight": "500",
|
||||
"labelMarginBottom": "8px"
|
||||
},
|
||||
|
||||
// 데이터 바인딩
|
||||
"tableName": "order_table",
|
||||
"columnName": "part_code",
|
||||
|
||||
// 필드 속성
|
||||
"required": true,
|
||||
"readonly": false,
|
||||
|
||||
// 컴포넌트별 설정
|
||||
"componentConfig": {
|
||||
"type": "text-input",
|
||||
"format": "none",
|
||||
"webType": "text",
|
||||
"multiline": false,
|
||||
"placeholder": "텍스트를 입력하세요"
|
||||
},
|
||||
|
||||
// 그리드 레이아웃
|
||||
"gridColumns": 5,
|
||||
"gridRowIndex": 0,
|
||||
"gridColumnStart": 1,
|
||||
"gridColumnSpan": "third",
|
||||
|
||||
// 기타
|
||||
"parentId": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 마이그레이션 전략: 하이브리드 방식
|
||||
|
||||
### 2.1 비파괴적 전환 (권장)
|
||||
|
||||
기존 필드를 유지하면서 새로운 필드를 추가하는 방식
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// 기존 필드 유지 (하위 호환성)
|
||||
"componentType": "text-input",
|
||||
"componentConfig": { ... },
|
||||
|
||||
// 신규 필드 추가
|
||||
"unifiedType": "UnifiedInput", // 새로운 통합 컴포넌트 타입
|
||||
"unifiedConfig": { // 새로운 설정 구조
|
||||
"type": "text",
|
||||
"format": "none",
|
||||
"placeholder": "텍스트를 입력하세요"
|
||||
},
|
||||
|
||||
// 마이그레이션 메타데이터
|
||||
"_migration": {
|
||||
"version": "2.0",
|
||||
"migratedAt": "2024-12-19T00:00:00Z",
|
||||
"migratedBy": "system",
|
||||
"originalType": "text-input"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 렌더링 로직 수정
|
||||
|
||||
```typescript
|
||||
// 렌더러에서 unifiedType 우선 사용
|
||||
function renderComponent(props: ComponentProps) {
|
||||
// 신규 타입이 있으면 Unified 컴포넌트 사용
|
||||
if (props.unifiedType) {
|
||||
return <UnifiedComponentRenderer
|
||||
type={props.unifiedType}
|
||||
config={props.unifiedConfig}
|
||||
/>;
|
||||
}
|
||||
|
||||
// 없으면 기존 레거시 컴포넌트 사용
|
||||
return <LegacyComponentRenderer
|
||||
type={props.componentType}
|
||||
config={props.componentConfig}
|
||||
/>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 컴포넌트별 매핑 규칙
|
||||
|
||||
### 3.1 text-input → UnifiedInput
|
||||
|
||||
```typescript
|
||||
// AS-IS
|
||||
{
|
||||
"componentType": "text-input",
|
||||
"componentConfig": {
|
||||
"type": "text-input",
|
||||
"format": "none",
|
||||
"webType": "text",
|
||||
"multiline": false,
|
||||
"placeholder": "텍스트를 입력하세요"
|
||||
}
|
||||
}
|
||||
|
||||
// TO-BE
|
||||
{
|
||||
"unifiedType": "UnifiedInput",
|
||||
"unifiedConfig": {
|
||||
"type": "text", // componentConfig.webType 또는 "text"
|
||||
"format": "none", // componentConfig.format
|
||||
"placeholder": "..." // componentConfig.placeholder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 number-input → UnifiedInput
|
||||
|
||||
```typescript
|
||||
// AS-IS
|
||||
{
|
||||
"componentType": "number-input",
|
||||
"componentConfig": {
|
||||
"type": "number-input",
|
||||
"webType": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1
|
||||
}
|
||||
}
|
||||
|
||||
// TO-BE
|
||||
{
|
||||
"unifiedType": "UnifiedInput",
|
||||
"unifiedConfig": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 select-basic → UnifiedSelect
|
||||
|
||||
```typescript
|
||||
// AS-IS (code 타입)
|
||||
{
|
||||
"componentType": "select-basic",
|
||||
"codeCategory": "ORDER_STATUS",
|
||||
"componentConfig": {
|
||||
"type": "select-basic",
|
||||
"webType": "code",
|
||||
"codeCategory": "ORDER_STATUS"
|
||||
}
|
||||
}
|
||||
|
||||
// TO-BE
|
||||
{
|
||||
"unifiedType": "UnifiedSelect",
|
||||
"unifiedConfig": {
|
||||
"mode": "dropdown",
|
||||
"source": "code",
|
||||
"codeGroup": "ORDER_STATUS"
|
||||
}
|
||||
}
|
||||
|
||||
// AS-IS (entity 타입)
|
||||
{
|
||||
"componentType": "select-basic",
|
||||
"componentConfig": {
|
||||
"type": "select-basic",
|
||||
"webType": "entity",
|
||||
"searchable": true,
|
||||
"valueField": "id",
|
||||
"displayField": "name"
|
||||
}
|
||||
}
|
||||
|
||||
// TO-BE
|
||||
{
|
||||
"unifiedType": "UnifiedSelect",
|
||||
"unifiedConfig": {
|
||||
"mode": "dropdown",
|
||||
"source": "entity",
|
||||
"searchable": true,
|
||||
"valueField": "id",
|
||||
"displayField": "name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 date-input → UnifiedDate
|
||||
|
||||
```typescript
|
||||
// AS-IS
|
||||
{
|
||||
"componentType": "date-input",
|
||||
"componentConfig": {
|
||||
"type": "date-input",
|
||||
"webType": "date",
|
||||
"format": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
|
||||
// TO-BE
|
||||
{
|
||||
"unifiedType": "UnifiedDate",
|
||||
"unifiedConfig": {
|
||||
"type": "date",
|
||||
"format": "YYYY-MM-DD"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 마이그레이션 스크립트
|
||||
|
||||
### 4.1 자동 마이그레이션 함수
|
||||
|
||||
```typescript
|
||||
// lib/migration/componentMigration.ts
|
||||
|
||||
interface MigrationResult {
|
||||
success: boolean;
|
||||
unifiedType: string;
|
||||
unifiedConfig: Record<string, any>;
|
||||
}
|
||||
|
||||
export function migrateToUnified(
|
||||
componentType: string,
|
||||
componentConfig: Record<string, any>
|
||||
): MigrationResult {
|
||||
|
||||
switch (componentType) {
|
||||
case 'text-input':
|
||||
return {
|
||||
success: true,
|
||||
unifiedType: 'UnifiedInput',
|
||||
unifiedConfig: {
|
||||
type: componentConfig.webType || 'text',
|
||||
format: componentConfig.format || 'none',
|
||||
placeholder: componentConfig.placeholder
|
||||
}
|
||||
};
|
||||
|
||||
case 'number-input':
|
||||
return {
|
||||
success: true,
|
||||
unifiedType: 'UnifiedInput',
|
||||
unifiedConfig: {
|
||||
type: 'number',
|
||||
min: componentConfig.min,
|
||||
max: componentConfig.max,
|
||||
step: componentConfig.step
|
||||
}
|
||||
};
|
||||
|
||||
case 'select-basic':
|
||||
return {
|
||||
success: true,
|
||||
unifiedType: 'UnifiedSelect',
|
||||
unifiedConfig: {
|
||||
mode: 'dropdown',
|
||||
source: componentConfig.webType || 'static',
|
||||
codeGroup: componentConfig.codeCategory,
|
||||
searchable: componentConfig.searchable,
|
||||
valueField: componentConfig.valueField,
|
||||
displayField: componentConfig.displayField
|
||||
}
|
||||
};
|
||||
|
||||
case 'date-input':
|
||||
return {
|
||||
success: true,
|
||||
unifiedType: 'UnifiedDate',
|
||||
unifiedConfig: {
|
||||
type: componentConfig.webType || 'date',
|
||||
format: componentConfig.format
|
||||
}
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
unifiedType: '',
|
||||
unifiedConfig: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 DB 마이그레이션 스크립트
|
||||
|
||||
```sql
|
||||
-- 마이그레이션 백업 테이블 생성
|
||||
CREATE TABLE screen_layouts_backup_v2 AS
|
||||
SELECT * FROM screen_layouts;
|
||||
|
||||
-- 마이그레이션 실행 (text-input 예시)
|
||||
UPDATE screen_layouts
|
||||
SET properties = properties || jsonb_build_object(
|
||||
'unifiedType', 'UnifiedInput',
|
||||
'unifiedConfig', jsonb_build_object(
|
||||
'type', COALESCE(properties->'componentConfig'->>'webType', 'text'),
|
||||
'format', COALESCE(properties->'componentConfig'->>'format', 'none'),
|
||||
'placeholder', properties->'componentConfig'->>'placeholder'
|
||||
),
|
||||
'_migration', jsonb_build_object(
|
||||
'version', '2.0',
|
||||
'migratedAt', NOW(),
|
||||
'originalType', 'text-input'
|
||||
)
|
||||
)
|
||||
WHERE properties->>'componentType' = 'text-input';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 롤백 전략
|
||||
|
||||
### 5.1 롤백 스크립트
|
||||
|
||||
```sql
|
||||
-- 마이그레이션 전 상태로 복원
|
||||
UPDATE screen_layouts sl
|
||||
SET properties = slb.properties
|
||||
FROM screen_layouts_backup_v2 slb
|
||||
WHERE sl.layout_id = slb.layout_id;
|
||||
|
||||
-- 또는 신규 필드만 제거
|
||||
UPDATE screen_layouts
|
||||
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration';
|
||||
```
|
||||
|
||||
### 5.2 단계적 롤백
|
||||
|
||||
```typescript
|
||||
// 특정 화면만 롤백
|
||||
async function rollbackScreen(screenId: number) {
|
||||
await db.query(`
|
||||
UPDATE screen_layouts sl
|
||||
SET properties = properties - 'unifiedType' - 'unifiedConfig' - '_migration'
|
||||
WHERE screen_id = $1
|
||||
`, [screenId]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 마이그레이션 일정
|
||||
|
||||
| 단계 | 작업 | 대상 | 시점 |
|
||||
|:---:|:---|:---|:---|
|
||||
| 1 | 백업 테이블 생성 | 전체 | Phase 1 시작 전 |
|
||||
| 2 | UnifiedInput 마이그레이션 | text-input, number-input | Phase 1 중 |
|
||||
| 3 | UnifiedSelect 마이그레이션 | select-basic | Phase 1 중 |
|
||||
| 4 | UnifiedDate 마이그레이션 | date-input | Phase 1 중 |
|
||||
| 5 | 검증 및 테스트 | 전체 | Phase 1 완료 후 |
|
||||
| 6 | 레거시 필드 제거 | 전체 | Phase 5 (추후) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 주의사항
|
||||
|
||||
1. **항상 백업 먼저**: 마이그레이션 전 반드시 백업 테이블 생성
|
||||
2. **점진적 전환**: 한 번에 모든 컴포넌트를 마이그레이션하지 않음
|
||||
3. **하위 호환성**: 기존 필드 유지로 롤백 가능하게
|
||||
4. **테스트 필수**: 각 마이그레이션 단계별 화면 테스트
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
# Unified Components 구현 완료 보고서
|
||||
|
||||
## 구현 일시
|
||||
|
||||
2024-12-19
|
||||
|
||||
## 구현된 컴포넌트 목록 (10개)
|
||||
|
||||
### Phase 1: 핵심 입력 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||
| :---------------- | :------------------ | :-------------------------------------------- | :---------------------- |
|
||||
| **UnifiedInput** | `UnifiedInput.tsx` | text, number, password, slider, color, button | 통합 입력 컴포넌트 |
|
||||
| **UnifiedSelect** | `UnifiedSelect.tsx` | dropdown, radio, check, tag, toggle, swap | 통합 선택 컴포넌트 |
|
||||
| **UnifiedDate** | `UnifiedDate.tsx` | date, time, datetime + range | 통합 날짜/시간 컴포넌트 |
|
||||
|
||||
### Phase 2: 레이아웃 및 그룹 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||
| :---------------- | :------------------ | :-------------------------------------------------------- | :--------------------- |
|
||||
| **UnifiedList** | `UnifiedList.tsx` | table, card, kanban, list | 통합 리스트 컴포넌트 |
|
||||
| **UnifiedLayout** | `UnifiedLayout.tsx` | grid, split, flex, divider, screen-embed | 통합 레이아웃 컴포넌트 |
|
||||
| **UnifiedGroup** | `UnifiedGroup.tsx` | tabs, accordion, section, card-section, modal, form-modal | 통합 그룹 컴포넌트 |
|
||||
|
||||
### Phase 3: 미디어 및 비즈니스 컴포넌트
|
||||
|
||||
| 컴포넌트 | 파일 | 모드/타입 | 설명 |
|
||||
| :------------------- | :--------------------- | :------------------------------------------------------------- | :---------------------- |
|
||||
| **UnifiedMedia** | `UnifiedMedia.tsx` | file, image, video, audio | 통합 미디어 컴포넌트 |
|
||||
| **UnifiedBiz** | `UnifiedBiz.tsx` | flow, rack, map, numbering, category, mapping, related-buttons | 통합 비즈니스 컴포넌트 |
|
||||
| **UnifiedHierarchy** | `UnifiedHierarchy.tsx` | tree, org, bom, cascading | 통합 계층 구조 컴포넌트 |
|
||||
|
||||
---
|
||||
|
||||
## 공통 인프라
|
||||
|
||||
### 설정 패널
|
||||
|
||||
- **DynamicConfigPanel**: JSON Schema 기반 동적 설정 UI 생성
|
||||
|
||||
### 렌더러
|
||||
|
||||
- **UnifiedComponentRenderer**: unifiedType에 따른 동적 컴포넌트 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/unified/
|
||||
├── index.ts # 모듈 인덱스
|
||||
├── UnifiedComponentRenderer.tsx # 동적 렌더러
|
||||
├── DynamicConfigPanel.tsx # JSON Schema 설정 패널
|
||||
├── UnifiedInput.tsx # 통합 입력
|
||||
├── UnifiedSelect.tsx # 통합 선택
|
||||
├── UnifiedDate.tsx # 통합 날짜
|
||||
├── UnifiedList.tsx # 통합 리스트
|
||||
├── UnifiedLayout.tsx # 통합 레이아웃
|
||||
├── UnifiedGroup.tsx # 통합 그룹
|
||||
├── UnifiedMedia.tsx # 통합 미디어
|
||||
├── UnifiedBiz.tsx # 통합 비즈니스
|
||||
└── UnifiedHierarchy.tsx # 통합 계층
|
||||
|
||||
frontend/types/
|
||||
└── unified-components.ts # 타입 정의
|
||||
|
||||
db/migrations/
|
||||
└── unified_component_schema.sql # DB 스키마 (미실행)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import {
|
||||
UnifiedInput,
|
||||
UnifiedSelect,
|
||||
UnifiedDate,
|
||||
UnifiedList,
|
||||
UnifiedComponentRenderer
|
||||
} from "@/components/unified";
|
||||
|
||||
// UnifiedInput 사용
|
||||
<UnifiedInput
|
||||
id="name"
|
||||
label="이름"
|
||||
required
|
||||
config={{ type: "text", placeholder: "이름을 입력하세요" }}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
/>
|
||||
|
||||
// UnifiedSelect 사용
|
||||
<UnifiedSelect
|
||||
id="status"
|
||||
label="상태"
|
||||
config={{
|
||||
mode: "dropdown",
|
||||
source: "code",
|
||||
codeGroup: "ORDER_STATUS",
|
||||
searchable: true
|
||||
}}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
|
||||
// UnifiedDate 사용
|
||||
<UnifiedDate
|
||||
id="orderDate"
|
||||
label="주문일"
|
||||
config={{ type: "date", format: "YYYY-MM-DD" }}
|
||||
value={orderDate}
|
||||
onChange={setOrderDate}
|
||||
/>
|
||||
|
||||
// UnifiedList 사용
|
||||
<UnifiedList
|
||||
id="orderList"
|
||||
label="주문 목록"
|
||||
config={{
|
||||
viewMode: "table",
|
||||
searchable: true,
|
||||
pageable: true,
|
||||
pageSize: 10,
|
||||
columns: [
|
||||
{ field: "orderId", header: "주문번호", sortable: true },
|
||||
{ field: "customerName", header: "고객명" },
|
||||
{ field: "orderDate", header: "주문일", format: "date" },
|
||||
]
|
||||
}}
|
||||
data={orders}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
```
|
||||
|
||||
### 동적 렌더링
|
||||
|
||||
```tsx
|
||||
import { UnifiedComponentRenderer } from "@/components/unified";
|
||||
|
||||
// unifiedType에 따라 자동으로 적절한 컴포넌트 렌더링
|
||||
<UnifiedComponentRenderer
|
||||
props={{
|
||||
unifiedType: "UnifiedInput",
|
||||
id: "dynamicField",
|
||||
label: "동적 필드",
|
||||
config: { type: "text" },
|
||||
value: fieldValue,
|
||||
onChange: setFieldValue,
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
### 기존 컴포넌트와의 공존
|
||||
|
||||
1. **기존 컴포넌트는 그대로 유지**: 모든 레거시 컴포넌트는 정상 동작
|
||||
2. **신규 화면에서만 Unified 컴포넌트 사용**: 기존 화면에 영향 없음
|
||||
3. **마이그레이션 없음**: 자동 마이그레이션 진행하지 않음
|
||||
|
||||
### 데이터베이스 마이그레이션
|
||||
|
||||
`db/migrations/unified_component_schema.sql` 파일은 아직 실행되지 않았습니다.
|
||||
필요시 수동으로 실행해야 합니다:
|
||||
|
||||
```bash
|
||||
psql -h localhost -U postgres -d plm_db -f db/migrations/unified_component_schema.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계 (선택)
|
||||
|
||||
1. **화면 관리 에디터 통합**: Unified 컴포넌트를 화면 에디터의 컴포넌트 팔레트에 추가
|
||||
2. **기존 비즈니스 컴포넌트 연동**: UnifiedBiz의 플레이스홀더를 실제 구현으로 교체
|
||||
3. **테스트 페이지 작성**: 모든 Unified 컴포넌트 데모 페이지
|
||||
4. **문서화**: 각 컴포넌트별 상세 사용 가이드
|
||||
|
||||
---
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- `PLAN_RENEWAL.md`: 리뉴얼 계획서
|
||||
- `docs/phase0-component-usage-analysis.md`: 컴포넌트 사용 현황 분석
|
||||
- `docs/phase0-migration-strategy.md`: 마이그레이션 전략 (참고용)
|
||||
|
||||
|
|
@ -589,3 +589,6 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -595,3 +595,6 @@ POST /multilang/keys/123/override
|
|||
| 1.0 | 2026-01-13 | AI | 최초 작성 |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -362,3 +362,6 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -348,3 +348,6 @@ const getComponentValue = (componentId: string) => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
# 집계 위젯 (Aggregation Widget) 개발 진행상황
|
||||
|
||||
## 개요
|
||||
데이터의 합계, 평균, 개수, 최대값, 최소값 등을 집계하여 표시하는 위젯
|
||||
|
||||
## 파일 위치
|
||||
- **V2 버전**: `frontend/lib/registry/components/v2-aggregation-widget/`
|
||||
- `index.ts` - 컴포넌트 정의
|
||||
- `types.ts` - 타입 정의
|
||||
- `AggregationWidgetComponent.tsx` - 메인 컴포넌트
|
||||
- `AggregationWidgetConfigPanel.tsx` - 설정 패널
|
||||
- `AggregationWidgetRenderer.tsx` - 렌더러
|
||||
|
||||
- **기존 버전**: `frontend/lib/registry/components/aggregation-widget/`
|
||||
|
||||
---
|
||||
|
||||
## 완료된 기능
|
||||
|
||||
### 1. 기본 집계 기능
|
||||
- [x] 테이블 데이터 조회 및 집계 (SUM, AVG, COUNT, MAX, MIN)
|
||||
- [x] 숫자형 컬럼 자동 감지 (`inputType` / `webType` 기반)
|
||||
- [x] 집계 결과 포맷팅 (숫자, 통화, 퍼센트)
|
||||
- [x] 가로/세로 레이아웃 지원
|
||||
|
||||
### 2. 데이터 소스 타입
|
||||
- [x] `table` - 테이블에서 직접 조회
|
||||
- [x] `component` - 다른 컴포넌트(리피터 등)에서 데이터 수신
|
||||
- [x] `selection` - 선택된 행 데이터로 집계
|
||||
|
||||
### 3. 필터 조건
|
||||
- [x] 필터 추가/삭제/활성화 UI
|
||||
- [x] 연산자: =, !=, >, >=, <, <=, LIKE, IN, IS NULL, IS NOT NULL
|
||||
- [x] 필터 결합 방식: AND / OR
|
||||
- [x] 값 소스 타입:
|
||||
- [x] `static` - 고정값 입력
|
||||
- [x] `formField` - 폼 필드에서 가져오기
|
||||
- [x] `selection` - 선택된 행에서 가져오기 (부분 완료)
|
||||
- [x] `urlParam` - URL 파라미터에서 가져오기
|
||||
- [x] 카테고리 타입 컬럼 - 콤보박스로 값 선택
|
||||
|
||||
### 4. 자동 새로고침
|
||||
- [x] `autoRefresh` - 주기적 새로고침
|
||||
- [x] `refreshInterval` - 새로고침 간격 (초)
|
||||
- [x] `refreshOnFormChange` - 폼 데이터 변경 시 새로고침
|
||||
|
||||
### 5. 스타일 설정
|
||||
- [x] 배경색, 테두리, 패딩
|
||||
- [x] 폰트 크기, 색상
|
||||
- [x] 라벨/아이콘 표시 여부
|
||||
|
||||
---
|
||||
|
||||
## 미완료 기능
|
||||
|
||||
### 1. 선택 데이터 필터 - 소스 컴포넌트 연동 (진행중)
|
||||
|
||||
**현재 상태**:
|
||||
- `FilterCondition`에 `sourceComponentId` 필드 추가됨
|
||||
- 설정 패널 UI에 소스 컴포넌트 선택 드롭다운 추가됨
|
||||
- 소스 컴포넌트 컬럼 로딩 함수 구현됨
|
||||
|
||||
**문제점**:
|
||||
- `screenComponents`가 빈 배열로 전달되어 소스 컴포넌트 목록이 표시되지 않음
|
||||
- `allComponents` → `screenComponents` 변환이 `getComponentConfigPanel.tsx`에서 수행되지만, 실제 컴포넌트 목록이 비어있음
|
||||
|
||||
**해결 필요 사항**:
|
||||
1. `UnifiedPropertiesPanel`에서 `allComponents`가 제대로 전달되는지 확인
|
||||
2. `getComponentConfigPanel.tsx`에서 `screenComponents` 변환 로직 디버깅
|
||||
3. 필터링 조건 확인 (table-list, v2-table-list, unified-repeater 등)
|
||||
|
||||
**관련 코드**:
|
||||
```typescript
|
||||
// types.ts - FilterCondition
|
||||
export interface FilterCondition {
|
||||
// ...
|
||||
sourceComponentId?: string; // 소스 컴포넌트 ID (NEW)
|
||||
sourceColumnName?: string; // 소스 컬럼명
|
||||
// ...
|
||||
}
|
||||
|
||||
// AggregationWidgetConfigPanel.tsx
|
||||
const selectableComponents = useMemo(() => {
|
||||
return screenComponents.filter(comp =>
|
||||
comp.componentType === "table-list" ||
|
||||
comp.componentType === "v2-table-list" ||
|
||||
// ...
|
||||
);
|
||||
}, [screenComponents]);
|
||||
```
|
||||
|
||||
### 2. 런타임 선택 데이터 연동
|
||||
|
||||
**현재 상태**:
|
||||
- `applyFilters` 함수에서 `selectedRows`를 사용하여 필터링
|
||||
- 하지만 특정 컴포넌트(`sourceComponentId`)의 선택 데이터를 가져오는 로직 미구현
|
||||
|
||||
**해결 필요 사항**:
|
||||
1. 각 컴포넌트별 선택 데이터를 관리하는 글로벌 상태 또는 이벤트 시스템 구현
|
||||
2. `selectionChange` 이벤트에서 `componentId`별로 선택 데이터 저장
|
||||
3. `applyFilters`에서 `sourceComponentId`에 해당하는 선택 데이터 사용
|
||||
|
||||
**예상 구현**:
|
||||
```typescript
|
||||
// 컴포넌트별 선택 데이터 저장 (전역 상태)
|
||||
const componentSelections = useRef<Record<string, any[]>>({});
|
||||
|
||||
// 이벤트 리스너
|
||||
window.addEventListener("selectionChange", (event) => {
|
||||
const { componentId, selectedData } = event.detail;
|
||||
componentSelections.current[componentId] = selectedData;
|
||||
});
|
||||
|
||||
// 필터 적용 시
|
||||
case "selection":
|
||||
const sourceData = componentSelections.current[filter.sourceComponentId];
|
||||
compareValue = sourceData?.[0]?.[filter.sourceColumnName];
|
||||
break;
|
||||
```
|
||||
|
||||
### 3. 리피터 컨테이너 내부 집계
|
||||
|
||||
**시나리오**:
|
||||
- 리피터 컨테이너 내부에 집계 위젯 배치
|
||||
- 각 반복 아이템별로 다른 집계 결과 표시
|
||||
|
||||
**현재 상태**:
|
||||
- 리피터가 `formData`에 현재 아이템 데이터를 전달
|
||||
- 필터에서 `valueSourceType: "formField"`를 사용하면 현재 아이템 기준 필터링 가능
|
||||
- 테스트 미완료
|
||||
|
||||
**테스트 필요 케이스**:
|
||||
1. 카테고리 리스트 리피터 + 집계 위젯 (해당 카테고리 상품 개수)
|
||||
2. 주문 리스트 리피터 + 집계 위젯 (해당 주문의 상품 금액 합계)
|
||||
|
||||
---
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용 (테이블 전체 집계)
|
||||
```
|
||||
데이터 소스: 테이블 → sales_order
|
||||
집계 항목:
|
||||
- 총 금액 (SUM of amount)
|
||||
- 주문 건수 (COUNT)
|
||||
- 평균 금액 (AVG of amount)
|
||||
```
|
||||
|
||||
### 필터 사용 (조건부 집계)
|
||||
```
|
||||
데이터 소스: 테이블 → sales_order
|
||||
필터 조건:
|
||||
- status = '완료'
|
||||
- order_date >= 2026-01-01
|
||||
집계 항목:
|
||||
- 완료 주문 금액 합계
|
||||
```
|
||||
|
||||
### 선택 데이터 연동 (목표)
|
||||
```
|
||||
좌측: 품목 테이블 리스트 (item_mng)
|
||||
우측: 집계 위젯
|
||||
|
||||
데이터 소스: 테이블 → sales_order
|
||||
필터 조건:
|
||||
- 컬럼: item_code
|
||||
- 연산자: 같음 (=)
|
||||
- 값 소스: 선택된 행
|
||||
- 소스 컴포넌트: 품목 리스트
|
||||
- 소스 컬럼: item_code
|
||||
|
||||
→ 품목 선택 시 해당 품목의 수주 금액 합계 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디버깅 로그
|
||||
|
||||
현재 설정 패널에 다음 로그가 추가되어 있음:
|
||||
```typescript
|
||||
console.log("[AggregationWidget] screenComponents:", screenComponents);
|
||||
console.log("[AggregationWidget] selectableComponents:", filtered);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **소스 컴포넌트 목록 표시 문제 해결**
|
||||
- `allComponents` 전달 경로 추적
|
||||
- `screenComponents` 변환 로직 확인
|
||||
|
||||
2. **컴포넌트별 선택 데이터 관리 구현**
|
||||
- 글로벌 상태 또는 Context 사용
|
||||
- `selectionChange` 이벤트 표준화
|
||||
|
||||
3. **리피터 내부 집계 테스트**
|
||||
- `formField` 필터로 현재 아이템 기준 집계 확인
|
||||
|
||||
4. **디버깅 로그 제거**
|
||||
- 개발 완료 후 콘솔 로그 정리
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- `frontend/lib/utils/getComponentConfigPanel.tsx` - `screenComponents` 변환
|
||||
- `frontend/components/screen/panels/UnifiedPropertiesPanel.tsx` - `allComponents` 전달
|
||||
- `frontend/components/screen/ScreenDesigner.tsx` - `layout.components` 전달
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
# 입력 컴포넌트 분석 및 통합 계획
|
||||
|
||||
> 작성일: 2024-12-23
|
||||
> 상태: 1차 정리 완료
|
||||
|
||||
## 분석 대상 컴포넌트 목록
|
||||
|
||||
| 번호 | 컴포넌트 ID | 한글명 | 패널 표시 | 통합 대상 |
|
||||
|------|-------------|--------|----------|----------|
|
||||
| 1 | rack-structure | 렉 구조 설정 | 숨김 | UnifiedBiz (rack) |
|
||||
| 2 | mail-recipient-selector | 메일 수신자 선택 | 숨김 | DataFlow 전용 |
|
||||
| 3 | repeater-field-group | 반복 필드 그룹 | 숨김 | 현재 사용 안함 |
|
||||
| 4 | universal-form-modal | 범용 폼 모달 | **유지** | 독립 유지 |
|
||||
| 5 | selected-items-detail-input | 선택 항목 상세입력 | **유지** | 독립 유지 |
|
||||
| 6 | entity-search-input | 엔티티 검색 입력 | 숨김 | UnifiedSelect (entity 모드) |
|
||||
| 7 | image-widget | 이미지 위젯 | 숨김 | UnifiedMedia (image) |
|
||||
| 8 | autocomplete-search-input | 자동완성 검색 입력 | 숨김 | UnifiedSelect (autocomplete 모드) |
|
||||
| 9 | location-swap-selector | 출발지/도착지 선택 | **유지** | 독립 유지 |
|
||||
| 10 | file-upload | 파일 업로드 | 숨김 | UnifiedMedia (file) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 렉 구조 설정 (rack-structure)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/rack-structure/`
|
||||
- **주요 기능**:
|
||||
- 창고 렉 위치를 열 범위와 단 수로 일괄 생성
|
||||
- 조건별 설정 (렉 라인, 열 범위, 단 수)
|
||||
- 미리보기 및 통계 표시
|
||||
- 템플릿 저장/불러오기
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 1200 x 800
|
||||
|
||||
### 분석
|
||||
- WMS(창고관리) 전용 특수 컴포넌트
|
||||
- 복잡한 비즈니스 로직 포함 (위치 코드 자동 생성)
|
||||
- formData 컨텍스트 의존 (창고ID, 층, 구역 등)
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedBiz` 컴포넌트의 `rack` 비즈니스 타입으로 통합
|
||||
- **이유**: 비즈니스 특화 컴포넌트이므로 UnifiedBiz가 적합
|
||||
- **작업**:
|
||||
- UnifiedBiz에서 bizType="rack" 선택 시 RackStructureComponent 렌더링
|
||||
- 설정 패널 통합
|
||||
|
||||
---
|
||||
|
||||
## 2. 메일 수신자 선택 (mail-recipient-selector)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/mail-recipient-selector/`
|
||||
- **주요 기능**:
|
||||
- 내부 인원 선택 (user_info 테이블)
|
||||
- 외부 이메일 직접 입력
|
||||
- 수신자(To) / 참조(CC) 구분
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 400 x 200
|
||||
|
||||
### 분석
|
||||
- 메일 발송 워크플로우 전용 컴포넌트
|
||||
- 내부 사용자 검색 + 외부 이메일 입력 복합 기능
|
||||
- DataFlow 노드에서 참조됨 (EmailActionProperties)
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: **독립 유지**
|
||||
- **이유**:
|
||||
- 메일 시스템 전용 복합 기능
|
||||
- 다른 컴포넌트와 기능이 겹치지 않음
|
||||
- DataFlow와의 긴밀한 연동
|
||||
|
||||
---
|
||||
|
||||
## 3. 반복 필드 그룹 (repeater-field-group)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/components/webtypes/RepeaterInput.tsx`, `frontend/components/webtypes/config/RepeaterConfigPanel.tsx`
|
||||
- **주요 기능**:
|
||||
- 동적 항목 추가/제거
|
||||
- 다양한 필드 타입 지원 (text, number, select, category, calculated 등)
|
||||
- 계산식 필드 (합계, 평균 등)
|
||||
- 레이아웃 옵션 (grid, table, card)
|
||||
- 드래그앤드롭 순서 변경
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 화면 설정에 따라 동적
|
||||
|
||||
### 분석
|
||||
- 매우 복잡한 컴포넌트 (943줄)
|
||||
- 견적서, 주문서 등 반복 입력이 필요한 화면에서 핵심 역할
|
||||
- 카테고리 매핑, 계산식, 반응형 지원
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: **독립 유지**
|
||||
- **이유**:
|
||||
- 너무 복잡하고 기능이 방대함
|
||||
- 이미 잘 동작하고 있음
|
||||
- 통합 시 오히려 유지보수 어려워짐
|
||||
|
||||
---
|
||||
|
||||
## 4. 범용 폼 모달 (universal-form-modal)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/universal-form-modal/`
|
||||
- **주요 기능**:
|
||||
- 섹션 기반 폼 레이아웃
|
||||
- 반복 섹션 (겸직 등록 등)
|
||||
- 채번규칙 연동
|
||||
- 다중 행 저장
|
||||
- 외부 데이터 수신
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 800 x 600
|
||||
|
||||
### 분석
|
||||
- ScreenModal, SaveModal과 기능 중복 가능성
|
||||
- 섹션 기반 레이아웃이 핵심 차별점
|
||||
- 복잡한 입력 시나리오 지원
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedGroup`의 `formModal` 타입으로 통합 검토
|
||||
- **현실적 접근**:
|
||||
- 당장 통합보다는 ScreenModal 시스템과의 차별화 유지
|
||||
- 향후 섹션 기반 레이아웃 기능을 ScreenModal에 반영
|
||||
|
||||
---
|
||||
|
||||
## 5. 선택 항목 상세입력 (selected-items-detail-input)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/selected-items-detail-input/`
|
||||
- **주요 기능**:
|
||||
- 선택된 데이터 목록 표시
|
||||
- 각 항목별 추가 필드 입력
|
||||
- 레이아웃 옵션 (grid, table)
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 800 x 400
|
||||
|
||||
### 분석
|
||||
- RepeatScreenModal과 연계되는 컴포넌트
|
||||
- 선택된 항목에 대한 상세 정보 일괄 입력 용도
|
||||
- 특수한 사용 사례 (품목 선택 후 수량 입력 등)
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: **독립 유지**
|
||||
- **이유**:
|
||||
- 특수한 워크플로우 지원
|
||||
- 다른 컴포넌트와 기능 중복 없음
|
||||
|
||||
---
|
||||
|
||||
## 6. 엔티티 검색 입력 (entity-search-input)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/entity-search-input/`
|
||||
- **주요 기능**:
|
||||
- 콤보박스 모드 (inline)
|
||||
- 모달 검색 모드
|
||||
- 추가 필드 표시 옵션
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 300 x 40
|
||||
- **webType**: entity
|
||||
|
||||
### 분석
|
||||
- UnifiedSelect의 entity 모드와 기능 중복
|
||||
- 모달 검색 기능이 차별점
|
||||
- EntityWidget과도 유사
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedSelect` entity 모드로 통합
|
||||
- **작업**:
|
||||
- UnifiedSelect에 `searchMode: "modal" | "inline" | "autocomplete"` 옵션 추가
|
||||
- 모달 검색 UI 통합
|
||||
- 기존 entity-search-input은 deprecated 처리
|
||||
|
||||
---
|
||||
|
||||
## 7. 이미지 위젯 (image-widget)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/image-widget/`
|
||||
- **주요 기능**:
|
||||
- 이미지 업로드
|
||||
- 미리보기
|
||||
- 드래그앤드롭 지원
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 200 x 200
|
||||
- **webType**: image
|
||||
|
||||
### 분석
|
||||
- UnifiedMedia의 ImageUploader와 기능 동일
|
||||
- 이미 ImageWidget 컴포넌트 재사용 중
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedMedia` image 타입으로 통합 완료
|
||||
- **상태**: 이미 UnifiedMedia.ImageUploader로 구현됨
|
||||
- **작업**:
|
||||
- 컴포넌트 패널에서 image-widget 제거
|
||||
- UnifiedMedia 사용 권장
|
||||
|
||||
---
|
||||
|
||||
## 8. 자동완성 검색 입력 (autocomplete-search-input)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/autocomplete-search-input/`
|
||||
- **주요 기능**:
|
||||
- 타이핑 시 드롭다운 검색
|
||||
- 엔티티 테이블 연동
|
||||
- 추가 필드 표시
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 300 x 40
|
||||
- **webType**: entity
|
||||
|
||||
### 분석
|
||||
- entity-search-input과 유사하지만 UI 방식이 다름
|
||||
- Command/Popover 기반 자동완성
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedSelect` entity 모드의 autocomplete 옵션으로 통합
|
||||
- **작업**:
|
||||
- UnifiedSelect에서 `searchMode: "autocomplete"` 옵션 추가
|
||||
- 자동완성 검색 로직 통합
|
||||
|
||||
---
|
||||
|
||||
## 9. 출발지/도착지 선택 (location-swap-selector)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/location-swap-selector/`
|
||||
- **주요 기능**:
|
||||
- 출발지/도착지 두 개 필드 동시 관리
|
||||
- 스왑 버튼으로 교환
|
||||
- 모바일 최적화 UI
|
||||
- 다양한 데이터 소스 (table, code, static)
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 400 x 100
|
||||
|
||||
### 분석
|
||||
- 물류/운송 시스템 전용 컴포넌트
|
||||
- 두 개의 Select를 묶은 복합 컴포넌트
|
||||
- 스왑 기능이 핵심
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: **독립 유지**
|
||||
- **이유**:
|
||||
- 특수 용도 (물류 시스템)
|
||||
- 다른 컴포넌트와 기능 중복 없음
|
||||
- 복합 필드 관리 (출발지 + 도착지)
|
||||
|
||||
---
|
||||
|
||||
## 10. 파일 업로드 (file-upload)
|
||||
|
||||
### 현재 구현
|
||||
- **위치**: `frontend/lib/registry/components/file-upload/`
|
||||
- **주요 기능**:
|
||||
- 파일 선택/업로드
|
||||
- 드래그앤드롭
|
||||
- 업로드 진행률 표시
|
||||
- 파일 목록 관리
|
||||
- **카테고리**: INPUT
|
||||
- **크기**: 350 x 240
|
||||
- **webType**: file
|
||||
|
||||
### 분석
|
||||
- UnifiedMedia의 FileUploader와 기능 동일
|
||||
- attach_file_info 테이블 연동
|
||||
|
||||
### 통합 방안
|
||||
- **결정**: `UnifiedMedia` file 타입으로 통합
|
||||
- **상태**: 이미 UnifiedMedia.FileUploader로 구현됨
|
||||
- **작업**:
|
||||
- 컴포넌트 패널에서 file-upload 제거
|
||||
- UnifiedMedia 사용 권장
|
||||
|
||||
---
|
||||
|
||||
## 통합 우선순위 및 작업 계획
|
||||
|
||||
### Phase 1: 즉시 통합 가능 (작업 최소)
|
||||
|
||||
| 컴포넌트 | 통합 대상 | 예상 작업량 | 비고 |
|
||||
|----------|----------|------------|------|
|
||||
| image-widget | UnifiedMedia (image) | 1일 | 이미 구현됨, 패널에서 숨기기만 |
|
||||
| file-upload | UnifiedMedia (file) | 1일 | 이미 구현됨, 패널에서 숨기기만 |
|
||||
|
||||
### Phase 2: 기능 통합 필요 (중간 작업)
|
||||
|
||||
| 컴포넌트 | 통합 대상 | 예상 작업량 | 비고 |
|
||||
|----------|----------|------------|------|
|
||||
| entity-search-input | UnifiedSelect (entity) | 3일 | 모달 검색 모드 추가 |
|
||||
| autocomplete-search-input | UnifiedSelect (entity) | 2일 | autocomplete 모드 추가 |
|
||||
| rack-structure | UnifiedBiz (rack) | 2일 | 비즈니스 타입 연결 |
|
||||
|
||||
### Phase 3: 독립 유지 (작업 없음)
|
||||
|
||||
| 컴포넌트 | 이유 |
|
||||
|----------|------|
|
||||
| mail-recipient-selector | 메일 시스템 전용 |
|
||||
| repeater-field-group | 너무 복잡, 잘 동작 중 |
|
||||
| universal-form-modal | ScreenModal과 차별화 필요 |
|
||||
| selected-items-detail-input | 특수 워크플로우 |
|
||||
| location-swap-selector | 물류 시스템 전용 |
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
### 즉시 실행 가능한 작업
|
||||
1. **ComponentsPanel 정리**:
|
||||
- `image-widget`, `file-upload` 숨김 처리 (UnifiedMedia 사용)
|
||||
- 중복 컴포넌트 정리
|
||||
|
||||
2. **UnifiedBiz 연결**:
|
||||
- `bizType: "rack"` 선택 시 `RackStructureComponent` 렌더링 연결
|
||||
|
||||
### 향후 계획
|
||||
1. UnifiedSelect에 entity 검색 모드 통합
|
||||
2. UnifiedMedia 설정 패널 강화
|
||||
3. 독립 유지 컴포넌트들의 문서화
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 패널 정리 제안
|
||||
|
||||
### 숨길 컴포넌트 (Unified로 대체됨)
|
||||
- `image-widget` → UnifiedMedia 사용
|
||||
- `file-upload` → UnifiedMedia 사용
|
||||
- `entity-search-input` → UnifiedSelect (entity 모드) 사용 예정
|
||||
- `autocomplete-search-input` → UnifiedSelect (autocomplete 모드) 사용 예정
|
||||
|
||||
### 유지할 컴포넌트 (독립 기능)
|
||||
- `rack-structure` - WMS 전용 (UnifiedBiz 연결 예정)
|
||||
- `mail-recipient-selector` - 메일 시스템 전용
|
||||
- `repeater-field-group` - 반복 입력 전용
|
||||
- `universal-form-modal` - 복잡한 폼 전용
|
||||
- `selected-items-detail-input` - 상세 입력 전용
|
||||
- `location-swap-selector` - 물류 시스템 전용
|
||||
|
||||
|
|
@ -4,12 +4,13 @@ import { useState, useEffect, useCallback } from "react";
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
|
||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
|
||||
import ScreenList from "@/components/screen/ScreenList";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import TemplateManager from "@/components/screen/TemplateManager";
|
||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
||||
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
|
||||
import { UnifiedComponentsDemo } from "@/components/unified";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
|
@ -17,7 +18,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||
|
||||
// 단계별 진행을 위한 타입 정의
|
||||
type Step = "list" | "design" | "template";
|
||||
type Step = "list" | "design" | "template" | "unified-test";
|
||||
type ViewMode = "tree" | "table";
|
||||
|
||||
export default function ScreenManagementPage() {
|
||||
|
|
@ -117,9 +118,9 @@ export default function ScreenManagementPage() {
|
|||
const filteredScreens = searchKeywords.length > 1
|
||||
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
||||
: screens.filter((screen) =>
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
if (isDesignMode) {
|
||||
|
|
@ -130,6 +131,15 @@ export default function ScreenManagementPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Unified 컴포넌트 테스트 모드
|
||||
if (currentStep === "unified-test") {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<UnifiedComponentsDemo onBack={() => goToStep("list")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||
{/* 페이지 헤더 */}
|
||||
|
|
@ -140,6 +150,15 @@ export default function ScreenManagementPage() {
|
|||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Unified 컴포넌트 테스트 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => goToNextStep("unified-test")}
|
||||
className="gap-2"
|
||||
>
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
Unified 테스트
|
||||
</Button>
|
||||
{/* 뷰 모드 전환 */}
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
||||
<TabsList className="h-9">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,18 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Plus,
|
||||
Activity,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -21,6 +32,8 @@ import { commonCodeApi } from "@/lib/api/commonCode";
|
|||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
|
|
@ -60,6 +73,7 @@ interface ColumnTypeInfo {
|
|||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
||||
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
|
||||
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
|
||||
}
|
||||
|
||||
interface SecondLevelMenu {
|
||||
|
|
@ -94,11 +108,16 @@ export default function TableManagementPage() {
|
|||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||
|
||||
// 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
|
||||
const [entityComboboxOpen, setEntityComboboxOpen] = useState<Record<string, {
|
||||
table: boolean;
|
||||
joinColumn: boolean;
|
||||
displayColumn: boolean;
|
||||
}>>({});
|
||||
const [entityComboboxOpen, setEntityComboboxOpen] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
table: boolean;
|
||||
joinColumn: boolean;
|
||||
displayColumn: boolean;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
|
||||
// DDL 기능 관련 상태
|
||||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||||
|
|
@ -112,6 +131,11 @@ export default function TableManagementPage() {
|
|||
// 🆕 Category 타입용: 2레벨 메뉴 목록
|
||||
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
|
||||
|
||||
// 🆕 Numbering 타입용: 채번규칙 목록
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 로그 뷰어 상태
|
||||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
||||
|
|
@ -263,6 +287,25 @@ export default function TableManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 채번규칙 목록 로드
|
||||
const loadNumberingRules = async () => {
|
||||
setNumberingRulesLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
} else {
|
||||
console.warn("⚠️ 채번규칙 로드 실패:", response);
|
||||
setNumberingRules([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 채번규칙 로드 에러:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setNumberingRulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTables = async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -304,14 +347,22 @@ export default function TableManagementPage() {
|
|||
|
||||
// 컬럼 데이터에 기본값 설정
|
||||
const processedColumns = (data.columns || data).map((col: any) => {
|
||||
// detailSettings에서 hierarchyRole 추출
|
||||
// detailSettings에서 hierarchyRole, numberingRuleId 추출
|
||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||
let numberingRuleId: string | undefined = undefined;
|
||||
if (col.detailSettings && typeof col.detailSettings === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(col.detailSettings);
|
||||
if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") {
|
||||
if (
|
||||
parsed.hierarchyRole === "large" ||
|
||||
parsed.hierarchyRole === "medium" ||
|
||||
parsed.hierarchyRole === "small"
|
||||
) {
|
||||
hierarchyRole = parsed.hierarchyRole;
|
||||
}
|
||||
if (parsed.numberingRuleId) {
|
||||
numberingRuleId = parsed.numberingRuleId;
|
||||
}
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 무시
|
||||
}
|
||||
|
|
@ -320,6 +371,7 @@ export default function TableManagementPage() {
|
|||
return {
|
||||
...col,
|
||||
inputType: col.inputType || "text", // 기본값: text
|
||||
numberingRuleId, // 🆕 채번규칙 ID
|
||||
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
||||
hierarchyRole, // 계층구조 역할
|
||||
};
|
||||
|
|
@ -407,7 +459,7 @@ export default function TableManagementPage() {
|
|||
const existingHierarchyRole = hierarchyRole;
|
||||
newDetailSettings = JSON.stringify({
|
||||
codeCategory: value,
|
||||
hierarchyRole: existingHierarchyRole
|
||||
hierarchyRole: existingHierarchyRole,
|
||||
});
|
||||
codeCategory = value;
|
||||
codeValue = value;
|
||||
|
|
@ -557,6 +609,38 @@ export default function TableManagementPage() {
|
|||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
// 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함
|
||||
console.log("🔍 Numbering 저장 체크:", {
|
||||
inputType: column.inputType,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
hasNumberingRuleId: !!column.numberingRuleId,
|
||||
});
|
||||
|
||||
if (column.inputType === "numbering") {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) {
|
||||
try {
|
||||
existingSettings = JSON.parse(finalDetailSettings);
|
||||
} catch {
|
||||
existingSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
// numberingRuleId가 있으면 저장, 없으면 제거
|
||||
if (column.numberingRuleId) {
|
||||
const numberingSettings = {
|
||||
...existingSettings,
|
||||
numberingRuleId: column.numberingRuleId,
|
||||
};
|
||||
finalDetailSettings = JSON.stringify(numberingSettings);
|
||||
console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings);
|
||||
} else {
|
||||
// numberingRuleId가 없으면 빈 객체
|
||||
finalDetailSettings = JSON.stringify(existingSettings);
|
||||
console.log("🔧 Numbering 규칙 없이 저장:", existingSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
|
|
@ -826,6 +910,7 @@ export default function TableManagementPage() {
|
|||
loadTables();
|
||||
loadCommonCodeCategories();
|
||||
loadSecondLevelMenus();
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
|
||||
|
|
@ -1338,63 +1423,7 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||
</p>
|
||||
) : (
|
||||
secondLevelMenus.map((menu) => {
|
||||
// menuObjid를 숫자로 변환하여 비교
|
||||
const menuObjidNum = Number(menu.menuObjid);
|
||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||
|
||||
return (
|
||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const currentMenus = column.categoryMenus || [];
|
||||
const newMenus = e.target.checked
|
||||
? [...currentMenus, menuObjidNum]
|
||||
: currentMenus.filter((id) => id !== menuObjidNum);
|
||||
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col,
|
||||
),
|
||||
);
|
||||
}}
|
||||
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="flex-1 cursor-pointer text-xs"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||
<p className="text-primary text-xs">
|
||||
{column.categoryMenus.length}개 메뉴 선택됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
|
|
@ -1418,8 +1447,8 @@ export default function TableManagementPage() {
|
|||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{column.referenceTable && column.referenceTable !== "none"
|
||||
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label ||
|
||||
column.referenceTable
|
||||
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)
|
||||
?.label || column.referenceTable
|
||||
: "테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
|
@ -1437,10 +1466,17 @@ export default function TableManagementPage() {
|
|||
key={option.value}
|
||||
value={`${option.label} ${option.value}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity", option.value);
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity",
|
||||
option.value,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], table: false },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
table: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -1448,13 +1484,17 @@ export default function TableManagementPage() {
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceTable === option.value ? "opacity-100" : "opacity-0",
|
||||
column.referenceTable === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{option.value !== "none" && (
|
||||
<span className="text-muted-foreground text-[10px]">{option.value}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{option.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
|
@ -1485,9 +1525,13 @@ export default function TableManagementPage() {
|
|||
role="combobox"
|
||||
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
|
||||
disabled={
|
||||
!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0
|
||||
}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
{!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
|
|
@ -1511,10 +1555,17 @@ export default function TableManagementPage() {
|
|||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_reference_column", "none");
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
"none",
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
joinColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -1522,7 +1573,9 @@ export default function TableManagementPage() {
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceColumn === "none" || !column.referenceColumn ? "opacity-100" : "opacity-0",
|
||||
column.referenceColumn === "none" || !column.referenceColumn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
|
|
@ -1532,10 +1585,17 @@ export default function TableManagementPage() {
|
|||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName);
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
refCol.columnName,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
joinColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -1543,13 +1603,17 @@ export default function TableManagementPage() {
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
||||
column.referenceColumn === refCol.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.columnLabel && (
|
||||
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.columnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
|
@ -1574,7 +1638,10 @@ export default function TableManagementPage() {
|
|||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: open },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: open,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
|
|
@ -1582,11 +1649,17 @@ export default function TableManagementPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||
aria-expanded={
|
||||
entityComboboxOpen[column.columnName]?.displayColumn || false
|
||||
}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
|
||||
disabled={
|
||||
!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0
|
||||
}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
{!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
|
|
@ -1610,10 +1683,17 @@ export default function TableManagementPage() {
|
|||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_display_column", "none");
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
"none",
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -1621,7 +1701,9 @@ export default function TableManagementPage() {
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === "none" || !column.displayColumn ? "opacity-100" : "opacity-0",
|
||||
column.displayColumn === "none" || !column.displayColumn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
|
|
@ -1631,10 +1713,17 @@ export default function TableManagementPage() {
|
|||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName);
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
refCol.columnName,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -1642,13 +1731,17 @@ export default function TableManagementPage() {
|
|||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
||||
column.displayColumn === refCol.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.columnLabel && (
|
||||
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.columnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
|
@ -1675,6 +1768,122 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */}
|
||||
{column.inputType === "numbering" && (
|
||||
<div className="w-64">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">채번규칙</label>
|
||||
<Popover
|
||||
open={numberingComboboxOpen[column.columnName] || false}
|
||||
onOpenChange={(open) =>
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: open,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={numberingComboboxOpen[column.columnName] || false}
|
||||
disabled={numberingRulesLoading}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{numberingRulesLoading
|
||||
? "로딩 중..."
|
||||
: column.numberingRuleId
|
||||
? numberingRules.find((r) => r.ruleId === column.numberingRuleId)
|
||||
?.ruleName || column.numberingRuleId
|
||||
: "채번규칙 선택..."}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="규칙 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
채번규칙을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={async () => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
handleColumnChange(columnIndex, "numberingRuleId", undefined);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 🆕 자동 저장 (선택 해제)
|
||||
const updatedColumn = { ...column, numberingRuleId: undefined };
|
||||
await handleSaveColumn(updatedColumn);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
!column.numberingRuleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule) => (
|
||||
<CommandItem
|
||||
key={rule.ruleId}
|
||||
value={`${rule.ruleName} ${rule.ruleId}`}
|
||||
onSelect={async () => {
|
||||
const columnIndex = columns.findIndex(
|
||||
(c) => c.columnName === column.columnName,
|
||||
);
|
||||
// 상태 업데이트
|
||||
handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId);
|
||||
setNumberingComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: false,
|
||||
}));
|
||||
// 🆕 자동 저장
|
||||
const updatedColumn = { ...column, numberingRuleId: rule.ruleId };
|
||||
await handleSaveColumn(updatedColumn);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.numberingRuleId === rule.ruleId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rule.ruleName}</span>
|
||||
{rule.tableName && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{rule.tableName}.{rule.columnName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{column.numberingRuleId && (
|
||||
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-0.5 text-[10px]">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<span>규칙 설정됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-4">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/c
|
|||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가
|
||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -113,7 +115,7 @@ function ScreenViewPage() {
|
|||
// 편집 모달 이벤트 리스너 등록
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
|
||||
setEditModalConfig({
|
||||
screenId: event.detail.screenId,
|
||||
|
|
@ -227,6 +229,67 @@ function ScreenViewPage() {
|
|||
initAutoFill();
|
||||
}, [layout, user]);
|
||||
|
||||
// 🆕 조건부 비활성화/숨김 시 해당 필드 값 초기화
|
||||
// 조건 필드들의 값을 추적하여 변경 시에만 실행
|
||||
const conditionalFieldValues = useMemo(() => {
|
||||
if (!layout?.components) return "";
|
||||
|
||||
// 조건부 설정에 사용되는 필드들의 현재 값을 JSON 문자열로 만들어 비교
|
||||
const conditionFields = new Set<string>();
|
||||
layout.components.forEach((component) => {
|
||||
const conditional = (component as any).conditional;
|
||||
if (conditional?.enabled && conditional.field) {
|
||||
conditionFields.add(conditional.field);
|
||||
}
|
||||
});
|
||||
|
||||
const values: Record<string, any> = {};
|
||||
conditionFields.forEach((field) => {
|
||||
values[field] = (formData as Record<string, any>)[field];
|
||||
});
|
||||
|
||||
return JSON.stringify(values);
|
||||
}, [layout?.components, formData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layout?.components) return;
|
||||
|
||||
const fieldsToReset: string[] = [];
|
||||
|
||||
layout.components.forEach((component) => {
|
||||
const conditional = (component as any).conditional;
|
||||
if (!conditional?.enabled) return;
|
||||
|
||||
const conditionalResult = evaluateConditional(
|
||||
conditional,
|
||||
formData as Record<string, any>,
|
||||
layout.components,
|
||||
);
|
||||
|
||||
// 숨김 또는 비활성화 상태인 경우
|
||||
if (!conditionalResult.visible || conditionalResult.disabled) {
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = (formData as Record<string, any>)[fieldName];
|
||||
|
||||
// 값이 있으면 초기화 대상에 추가
|
||||
if (currentValue !== undefined && currentValue !== "" && currentValue !== null) {
|
||||
fieldsToReset.push(fieldName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 초기화할 필드가 있으면 한 번에 처리
|
||||
if (fieldsToReset.length > 0) {
|
||||
setFormData((prev) => {
|
||||
const updated = { ...prev };
|
||||
fieldsToReset.forEach((fieldName) => {
|
||||
updated[fieldName] = "";
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [conditionalFieldValues, layout?.components]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||
useEffect(() => {
|
||||
|
|
@ -264,8 +327,8 @@ function ScreenViewPage() {
|
|||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
||||
} else {
|
||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
||||
const MARGIN_X = 32;
|
||||
const availableWidth = containerWidth - MARGIN_X;
|
||||
const MARGIN_X = 32;
|
||||
const availableWidth = containerWidth - MARGIN_X;
|
||||
newScale = availableWidth / designWidth;
|
||||
}
|
||||
|
||||
|
|
@ -345,9 +408,10 @@ function ScreenViewPage() {
|
|||
|
||||
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
||||
{layoutReady && layout && layout.components.length > 0 ? (
|
||||
<div
|
||||
className="bg-background relative"
|
||||
style={{
|
||||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||
<div
|
||||
className="bg-background relative"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
height: `${screenHeight}px`,
|
||||
minWidth: `${screenWidth}px`,
|
||||
|
|
@ -425,8 +489,73 @@ function ScreenViewPage() {
|
|||
(c as any).componentType === "conditional-container",
|
||||
);
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
|
||||
const adjustedComponents = regularComponents.map((component) => {
|
||||
// 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬
|
||||
const autoLayoutComponents = (() => {
|
||||
// X 위치 기준으로 섹션 그룹화 (50px 오차 범위)
|
||||
const X_THRESHOLD = 50;
|
||||
const GAP = 16; // 컴포넌트 간 간격
|
||||
|
||||
// 컴포넌트를 X 섹션별로 그룹화
|
||||
const sections: Map<number, typeof regularComponents> = new Map();
|
||||
|
||||
regularComponents.forEach((comp) => {
|
||||
const x = comp.position.x;
|
||||
let foundSection = false;
|
||||
|
||||
for (const [sectionX, components] of sections.entries()) {
|
||||
if (Math.abs(x - sectionX) < X_THRESHOLD) {
|
||||
components.push(comp);
|
||||
foundSection = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundSection) {
|
||||
sections.set(x, [comp]);
|
||||
}
|
||||
});
|
||||
|
||||
// 각 섹션 내에서 Y 위치 순으로 정렬 후 자동 배치
|
||||
const adjustedMap = new Map<string, typeof regularComponents[0]>();
|
||||
|
||||
for (const [sectionX, components] of sections.entries()) {
|
||||
// 섹션 내 2개 이상 컴포넌트가 있을 때만 자동 배치
|
||||
if (components.length >= 2) {
|
||||
// Y 위치 순으로 정렬
|
||||
const sorted = [...components].sort((a, b) => a.position.y - b.position.y);
|
||||
|
||||
let currentY = sorted[0].position.y;
|
||||
|
||||
sorted.forEach((comp, index) => {
|
||||
if (index === 0) {
|
||||
adjustedMap.set(comp.id, comp);
|
||||
} else {
|
||||
// 이전 컴포넌트 아래로 배치
|
||||
const prevComp = sorted[index - 1];
|
||||
const prevAdjusted = adjustedMap.get(prevComp.id) || prevComp;
|
||||
const prevBottom = prevAdjusted.position.y + (prevAdjusted.size?.height || 100);
|
||||
const newY = prevBottom + GAP;
|
||||
|
||||
adjustedMap.set(comp.id, {
|
||||
...comp,
|
||||
position: {
|
||||
...comp.position,
|
||||
y: newY,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 단일 컴포넌트는 그대로
|
||||
components.forEach((comp) => adjustedMap.set(comp.id, comp));
|
||||
}
|
||||
}
|
||||
|
||||
return regularComponents.map((comp) => adjustedMap.get(comp.id) || comp);
|
||||
})();
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정
|
||||
const adjustedComponents = autoLayoutComponents.map((component) => {
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
const isConditionalContainer = (component as any).componentId === "conditional-container";
|
||||
|
||||
|
|
@ -447,30 +576,15 @@ function ScreenViewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 조정
|
||||
// 조건부 컨테이너 높이 조정
|
||||
for (const container of conditionalContainers) {
|
||||
const isBelow = component.position.y > container.position.y;
|
||||
const actualHeight = conditionalContainerHeights[container.id];
|
||||
const originalHeight = container.size?.height || 200;
|
||||
const heightDiff = actualHeight ? actualHeight - originalHeight : 0;
|
||||
|
||||
console.log(`🔍 높이 조정 체크:`, {
|
||||
componentId: component.id,
|
||||
componentY: component.position.y,
|
||||
containerY: container.position.y,
|
||||
isBelow,
|
||||
actualHeight,
|
||||
originalHeight,
|
||||
heightDiff,
|
||||
containerId: container.id,
|
||||
containerSize: container.size,
|
||||
});
|
||||
|
||||
if (isBelow && heightDiff > 0) {
|
||||
totalHeightAdjustment += heightDiff;
|
||||
console.log(
|
||||
`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -491,9 +605,30 @@ function ScreenViewPage() {
|
|||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{adjustedComponents.map((component) => {
|
||||
// 조건부 표시 설정이 있는 경우에만 평가
|
||||
const conditional = (component as any).conditional;
|
||||
let conditionalDisabled = false;
|
||||
|
||||
if (conditional?.enabled) {
|
||||
const conditionalResult = evaluateConditional(
|
||||
conditional,
|
||||
formData as Record<string, any>,
|
||||
layout?.components || [],
|
||||
);
|
||||
|
||||
// 조건에 따라 숨김 처리
|
||||
if (!conditionalResult.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 조건에 따라 비활성화 처리
|
||||
conditionalDisabled = conditionalResult.disabled;
|
||||
}
|
||||
|
||||
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
||||
return (
|
||||
<RealtimePreview
|
||||
conditionalDisabled={conditionalDisabled}
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
|
|
@ -769,7 +904,8 @@ function ScreenViewPage() {
|
|||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</ScreenMultiLangProvider>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { initializeRegistries } from "@/lib/registry/init";
|
||||
import { initV2Core, cleanupV2Core } from "@/lib/v2-core";
|
||||
|
||||
interface RegistryProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -18,11 +19,26 @@ export function RegistryProvider({ children }: RegistryProviderProps) {
|
|||
// 레지스트리 초기화
|
||||
try {
|
||||
initializeRegistries();
|
||||
|
||||
// V2 Core 초기화 (느슨한 결합 아키텍처)
|
||||
initV2Core({
|
||||
debug: process.env.NODE_ENV === "development",
|
||||
legacyBridge: {
|
||||
legacyToV2: true,
|
||||
v2ToLegacy: true,
|
||||
},
|
||||
});
|
||||
|
||||
setIsInitialized(true);
|
||||
} catch (error) {
|
||||
console.error("❌ 레지스트리 초기화 실패:", error);
|
||||
setIsInitialized(true); // 오류가 있어도 앱은 계속 실행
|
||||
}
|
||||
|
||||
// 정리 함수
|
||||
return () => {
|
||||
cleanupV2Core();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 초기화 중 로딩 표시 (선택사항)
|
||||
|
|
|
|||
|
|
@ -141,21 +141,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
selectedIds,
|
||||
} = event.detail;
|
||||
|
||||
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
|
||||
screenId,
|
||||
title,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
||||
|
||||
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||
setSelectedData(eventSelectedData);
|
||||
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
|
||||
} else {
|
||||
setSelectedData([]);
|
||||
}
|
||||
|
|
@ -168,22 +159,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
// pushState로 URL 변경 (페이지 새로고침 없이)
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||
|
||||
// 🆕 배열인 경우 두 가지 데이터를 설정:
|
||||
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
|
||||
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
|
||||
if (Array.isArray(editData)) {
|
||||
const firstRecord = editData[0] || {};
|
||||
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
|
||||
formData: "첫 번째 레코드 (일반 입력 필드용)",
|
||||
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
|
||||
});
|
||||
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
|
||||
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
|
||||
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
|
||||
|
|
@ -220,9 +204,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const sourceValue = rawParentData[mapping.sourceColumn];
|
||||
if (sourceValue !== undefined && sourceValue !== null) {
|
||||
parentData[mapping.targetColumn] = sourceValue;
|
||||
console.log(
|
||||
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${sourceValue}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,13 +228,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||
if (isLinkField) {
|
||||
parentData[key] = value;
|
||||
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(parentData).length > 0) {
|
||||
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
|
||||
setFormData(parentData);
|
||||
} else {
|
||||
setFormData({});
|
||||
|
|
@ -277,7 +256,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// dataSourceId 파라미터 제거
|
||||
currentUrl.searchParams.delete("dataSourceId");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
// console.log("🧹 URL 파라미터 제거");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
|
|
@ -292,8 +270,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
// console.log("🔄 연속 모드 초기화: false");
|
||||
localStorage.setItem("screenModal_continuousMode", "false");
|
||||
};
|
||||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
|
|
@ -301,36 +278,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
|
||||
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
|
||||
if (timeSinceOpen < 500) {
|
||||
// console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
|
||||
return;
|
||||
}
|
||||
|
||||
const isContinuousMode = continuousMode;
|
||||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
// console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
|
||||
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey((prev) => prev + 1);
|
||||
// console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
// 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
// console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
loadScreenData(modalState.screenId);
|
||||
}
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
// console.log("❌ 일반 모드 - 모달 닫기");
|
||||
handleCloseModal();
|
||||
}
|
||||
};
|
||||
|
|
@ -357,16 +322,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
console.log("화면 데이터 로딩 시작:", screenId);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
]);
|
||||
|
||||
console.log("API 응답:", { screenInfo, layoutData });
|
||||
|
||||
// 🆕 URL 파라미터 확인 (수정 모드)
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -381,31 +342,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
try {
|
||||
console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam });
|
||||
|
||||
const { dataApi } = await import("@/lib/api/data");
|
||||
|
||||
// groupByColumns 파싱
|
||||
let groupByColumns: string[] = [];
|
||||
if (groupByColumnsParam) {
|
||||
try {
|
||||
groupByColumns = JSON.parse(groupByColumnsParam);
|
||||
console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns);
|
||||
} catch (e) {
|
||||
console.warn("groupByColumns 파싱 실패:", e);
|
||||
} catch {
|
||||
// groupByColumns 파싱 실패 시 무시
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!");
|
||||
}
|
||||
|
||||
console.log("🚀 [ScreenModal] API 호출 직전:", {
|
||||
tableName,
|
||||
editId,
|
||||
enableEntityJoin: true,
|
||||
groupByColumns,
|
||||
groupByColumnsLength: groupByColumns.length,
|
||||
});
|
||||
|
||||
// 🆕 apiClient를 named import로 가져오기
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const params: any = {
|
||||
|
|
@ -413,7 +359,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
if (groupByColumns.length > 0) {
|
||||
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
|
||||
}
|
||||
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
|
||||
if (primaryKeyColumn) {
|
||||
|
|
@ -429,26 +374,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
|
||||
const response = apiResponse.data;
|
||||
|
||||
console.log("📩 [ScreenModal] API 응답 받음:", {
|
||||
success: response.success,
|
||||
hasData: !!response.data,
|
||||
dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음",
|
||||
dataLength: Array.isArray(response.data) ? response.data.length : 1,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 배열인 경우 (그룹핑) vs 단일 객체
|
||||
const isArray = Array.isArray(response.data);
|
||||
|
||||
if (isArray) {
|
||||
console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`);
|
||||
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||
} else {
|
||||
console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")");
|
||||
console.log("📊 모든 필드 키:", Object.keys(response.data));
|
||||
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||
}
|
||||
|
||||
// 🔧 날짜 필드 정규화 (타임존 제거)
|
||||
const normalizeDates = (data: any): any => {
|
||||
if (Array.isArray(data)) {
|
||||
|
|
@ -463,10 +389,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split("T")[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
normalized[key] = value.split("T")[0];
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
|
|
@ -474,31 +397,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return normalized;
|
||||
};
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
|
||||
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
|
||||
if (Array.isArray(normalizedData)) {
|
||||
console.log(
|
||||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||
);
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
|
||||
// setFormData 직후 확인
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 수정 데이터 조회 오류:", error);
|
||||
console.error("수정 데이터 조회 오류:", error);
|
||||
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
|
@ -520,11 +433,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
console.log("✅ 화면 관리 해상도 사용:", dimensions);
|
||||
} else {
|
||||
// 해상도 정보가 없으면 자동 계산
|
||||
dimensions = calculateScreenDimensions(components);
|
||||
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
|
||||
}
|
||||
|
||||
setScreenDimensions(dimensions);
|
||||
|
|
@ -533,11 +444,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
console.log("화면 데이터 설정 완료:", {
|
||||
componentsCount: components.length,
|
||||
dimensions,
|
||||
screenInfo,
|
||||
});
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
|
|
@ -559,7 +465,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
currentUrl.searchParams.delete("tableName");
|
||||
currentUrl.searchParams.delete("groupByColumns");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
|
|
@ -690,78 +595,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : screenData ? (
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
|
||||
// 🆕 formData 전달 확인 로그
|
||||
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
});
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
groupedData={selectedData}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
groupedData={selectedData}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -780,7 +673,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const isChecked = checked === true;
|
||||
setContinuousMode(isChecked);
|
||||
localStorage.setItem("screenModal_continuousMode", String(isChecked));
|
||||
console.log("🔄 연속 모드 변경:", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,7 @@ interface ManualConfigPanelProps {
|
|||
value?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
onChange: (config: any) => void;
|
||||
onChange: (config: { value?: string; placeholder?: string }) => void;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -20,17 +20,9 @@ export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
|
|||
}) => {
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium sm:text-sm">입력값</Label>
|
||||
<Input
|
||||
value={config.value || ""}
|
||||
onChange={(e) => onChange({ ...config, value: e.target.value })}
|
||||
placeholder={config.placeholder || "값을 입력하세요"}
|
||||
disabled={isPreview}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
코드 생성 시 이 값이 그대로 사용됩니다
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/50 bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
사용자가 폼에서 직접 입력합니다
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -42,6 +34,9 @@ export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
|
|||
disabled={isPreview}
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
number: { numberLength: 4, numberValue: 1 },
|
||||
date: { dateFormat: "YYYYMMDD" },
|
||||
text: { textValue: "CODE" },
|
||||
category: { categoryKey: "", categoryMappings: [] },
|
||||
};
|
||||
onUpdate({
|
||||
partType: newPartType,
|
||||
|
|
|
|||
|
|
@ -6,17 +6,31 @@ 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 { Plus, Save, Edit2, Trash2 } from "lucide-react";
|
||||
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
|
||||
import { NumberingRuleCard } from "./NumberingRuleCard";
|
||||
import { NumberingRulePreview } from "./NumberingRulePreview";
|
||||
import {
|
||||
getAvailableNumberingRules,
|
||||
createNumberingRule,
|
||||
updateNumberingRule,
|
||||
deleteNumberingRule,
|
||||
saveNumberingRuleToTest,
|
||||
deleteNumberingRuleFromTest,
|
||||
} from "@/lib/api/numberingRule";
|
||||
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 카테고리 값 트리 노드 타입
|
||||
interface CategoryValueNode {
|
||||
valueId: number;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
depth: number;
|
||||
path: string;
|
||||
parentValueId: number | null;
|
||||
children?: CategoryValueNode[];
|
||||
}
|
||||
|
||||
interface NumberingRuleDesignerProps {
|
||||
initialConfig?: NumberingRuleConfig;
|
||||
|
|
@ -52,10 +66,96 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
||||
const [customSeparator, setCustomSeparator] = useState("");
|
||||
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
displayName: string; // "테이블명.컬럼명" 형식
|
||||
}
|
||||
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
|
||||
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
|
||||
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
|
||||
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
|
||||
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
|
||||
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
|
||||
}, []);
|
||||
|
||||
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule?.categoryColumn) {
|
||||
setSelectedCategoryKey(currentRule.categoryColumn);
|
||||
} else {
|
||||
setSelectedCategoryKey("");
|
||||
}
|
||||
}, [currentRule?.categoryColumn]);
|
||||
|
||||
// 카테고리 키 선택 시 해당 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
if (selectedCategoryKey) {
|
||||
const [tableName, columnName] = selectedCategoryKey.split(".");
|
||||
if (tableName && columnName) {
|
||||
loadCategoryValues(tableName, columnName);
|
||||
}
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
}, [selectedCategoryKey]);
|
||||
|
||||
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
|
||||
const loadAllCategoryOptions = async () => {
|
||||
try {
|
||||
// category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회
|
||||
const response = await getAllCategoryKeys();
|
||||
if (response.success && response.data) {
|
||||
const options: CategoryOption[] = response.data.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
columnName: item.columnName,
|
||||
displayName: `${item.tableName}.${item.columnName}`,
|
||||
}));
|
||||
setAllCategoryOptions(options);
|
||||
console.log("전체 카테고리 옵션 로드:", options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 옵션 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 카테고리 컬럼의 값 트리 조회
|
||||
const loadCategoryValues = async (tableName: string, columnName: string) => {
|
||||
setLoadingCategories(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
setCategoryValues(response.data);
|
||||
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 트리 조회 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
|
||||
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
flattenCategoryValues(node.children, result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategoryValues = flattenCategoryValues(categoryValues);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
@ -217,13 +317,16 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
});
|
||||
|
||||
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
|
||||
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
|
||||
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
|
||||
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
|
||||
const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global");
|
||||
|
||||
const ruleToSave = {
|
||||
...currentRule,
|
||||
parts: partsWithDefaults,
|
||||
scopeType: "menu" as const, // 메뉴 기반 채번규칙
|
||||
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
|
||||
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
|
||||
};
|
||||
|
||||
console.log("💾 채번 규칙 저장:", {
|
||||
|
|
@ -237,12 +340,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
ruleToSave,
|
||||
});
|
||||
|
||||
let response;
|
||||
if (existing) {
|
||||
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
|
||||
} else {
|
||||
response = await createNumberingRule(ruleToSave);
|
||||
}
|
||||
// 테스트 테이블에 저장 (numbering_rules_test)
|
||||
const response = await saveNumberingRuleToTest(ruleToSave);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSavedRules((prev) => {
|
||||
|
|
@ -278,7 +377,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
async (ruleId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await deleteNumberingRule(ruleId);
|
||||
const response = await deleteNumberingRuleFromTest(ruleId);
|
||||
|
||||
if (response.success) {
|
||||
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
|
||||
|
|
@ -479,18 +578,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
|
||||
{currentTableName && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">적용 테이블</Label>
|
||||
<div className="border-input bg-muted text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
|
||||
{currentTableName}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
이 규칙은 현재 화면의 테이블({currentTableName})에 자동으로 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -44,6 +44,22 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
|||
// 3. 날짜
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 placeholder 표시
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||
// 형식에 맞는 placeholder 반환
|
||||
switch (format) {
|
||||
case "YYYY": return "[YYYY]";
|
||||
case "YY": return "[YY]";
|
||||
case "YYYYMM": return "[YYYYMM]";
|
||||
case "YYMM": return "[YYMM]";
|
||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||
case "YYMMDD": return "[YYMMDD]";
|
||||
default: return "[DATE]";
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 기준 생성
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -33,9 +33,10 @@ import {
|
|||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react";
|
||||
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Code, Table, Settings, Database } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { screenApi, updateTabScreenReferences } from "@/lib/api/screen";
|
||||
import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -135,6 +136,15 @@ export default function CopyScreenModal({
|
|||
// 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만)
|
||||
const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all");
|
||||
|
||||
// 채번규칙 복제 옵션 (체크 시: 복제 → 메뉴 동기화 → 채번규칙 복제 순서로 실행)
|
||||
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||
|
||||
// 추가 복사 옵션들
|
||||
const [copyCodeCategory, setCopyCodeCategory] = useState(false); // 코드 카테고리 + 코드 복사
|
||||
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); // 카테고리 매핑 + 값 복사
|
||||
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사
|
||||
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 연쇄관계 설정 복사
|
||||
|
||||
// 복사 중 상태
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" });
|
||||
|
|
@ -584,6 +594,7 @@ export default function CopyScreenModal({
|
|||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: "MAIN",
|
||||
display_order: 1,
|
||||
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`);
|
||||
} catch (groupError) {
|
||||
|
|
@ -609,7 +620,7 @@ export default function CopyScreenModal({
|
|||
};
|
||||
|
||||
// 이름 변환 헬퍼 함수 (일괄 이름 변경 적용)
|
||||
const transformName = (originalName: string, isRootGroup: boolean = false): string => {
|
||||
const transformName = (originalName: string, isRootGroup: boolean = false, sourceCompanyCode?: string): string => {
|
||||
// 루트 그룹은 사용자가 직접 입력한 이름 사용
|
||||
if (isRootGroup) {
|
||||
return newGroupName.trim();
|
||||
|
|
@ -621,7 +632,12 @@ export default function CopyScreenModal({
|
|||
return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText);
|
||||
}
|
||||
|
||||
// 기본: "(복제)" 붙이기
|
||||
// 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음)
|
||||
if (sourceCompanyCode && sourceCompanyCode !== targetCompanyCode) {
|
||||
return originalName;
|
||||
}
|
||||
|
||||
// 같은 회사 내 복제: "(복제)" 붙이기 (중복 방지)
|
||||
return `${originalName} (복제)`;
|
||||
};
|
||||
|
||||
|
|
@ -633,17 +649,19 @@ export default function CopyScreenModal({
|
|||
screenCodes: string[], // 미리 생성된 화면 코드 배열
|
||||
codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달)
|
||||
stats: { groups: number; screens: number },
|
||||
totalScreenCount: number // 전체 화면 수 (진행률 표시용)
|
||||
totalScreenCount: number, // 전체 화면 수 (진행률 표시용)
|
||||
screenIdMap: { [key: number]: number } // 원본 화면 ID -> 새 화면 ID 매핑
|
||||
): Promise<void> => {
|
||||
// 1. 현재 그룹 생성 (원본 display_order 유지)
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.floor(Math.random() * 1000);
|
||||
const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`;
|
||||
|
||||
console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`);
|
||||
const transformedGroupName = transformName(sourceGroupData.group_name, false, sourceGroupData.company_code);
|
||||
console.log(`📁 그룹 생성: ${transformedGroupName}`);
|
||||
|
||||
const newGroupResponse = await createScreenGroup({
|
||||
group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용
|
||||
group_name: transformedGroupName, // 일괄 이름 변경 적용
|
||||
group_code: newGroupCode,
|
||||
parent_group_id: parentGroupId,
|
||||
target_company_code: targetCompany,
|
||||
|
|
@ -663,13 +681,29 @@ export default function CopyScreenModal({
|
|||
const sourceScreensInfo = sourceGroupData.screens || [];
|
||||
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
const screenName = typeof s === 'object' ? s.screen_name : '';
|
||||
const tableName = typeof s === 'object' ? s.table_name : '';
|
||||
|
||||
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
|
||||
let screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
if (!screenData && screenId && screenName) {
|
||||
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
|
||||
screenData = {
|
||||
screenId: screenId,
|
||||
screenName: screenName,
|
||||
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
|
||||
tableName: tableName || '',
|
||||
description: '',
|
||||
companyCode: sourceGroupData.company_code || '',
|
||||
} as any;
|
||||
}
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData); // 화면 데이터가 있는 것만
|
||||
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
|
||||
|
||||
// display_order 순으로 정렬
|
||||
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
|
|
@ -687,12 +721,13 @@ export default function CopyScreenModal({
|
|||
message: `화면 복제 중: ${screen.screenName}`
|
||||
});
|
||||
|
||||
console.log(` 📄 화면 복제: ${screen.screenName} → ${newScreenCode}`);
|
||||
const transformedScreenName = transformName(screen.screenName, false, sourceGroupData.company_code);
|
||||
console.log(` 📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`);
|
||||
|
||||
const result = await screenApi.copyScreenWithModals(screen.screenId, {
|
||||
targetCompanyCode: targetCompany,
|
||||
mainScreen: {
|
||||
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
|
||||
screenName: transformedScreenName, // 일괄 이름 변경 적용
|
||||
screenCode: newScreenCode,
|
||||
description: screen.description || "",
|
||||
},
|
||||
|
|
@ -700,14 +735,18 @@ export default function CopyScreenModal({
|
|||
});
|
||||
|
||||
if (result.mainScreen?.screenId) {
|
||||
// 원본 화면 ID -> 새 화면 ID 매핑 기록
|
||||
screenIdMap[screen.screenId] = result.mainScreen.screenId;
|
||||
|
||||
await addScreenToGroup({
|
||||
group_id: newGroup.id,
|
||||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: screenRole || "MAIN",
|
||||
display_order: displayOrder, // 원본 정렬순서 유지
|
||||
target_company_code: targetCompany, // 대상 회사 코드 전달
|
||||
});
|
||||
stats.screens++;
|
||||
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
|
||||
console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`);
|
||||
}
|
||||
} catch (screenError) {
|
||||
console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError);
|
||||
|
|
@ -730,7 +769,8 @@ export default function CopyScreenModal({
|
|||
screenCodes,
|
||||
codeIndex,
|
||||
stats,
|
||||
totalScreenCount
|
||||
totalScreenCount,
|
||||
screenIdMap // screenIdMap 전달
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -769,6 +809,7 @@ export default function CopyScreenModal({
|
|||
|
||||
const finalCompanyCode = targetCompanyCode || sourceGroup.company_code;
|
||||
const stats = { groups: 0, screens: 0 };
|
||||
const screenIdMap: { [key: number]: number } = {}; // 원본 화면 ID -> 새 화면 ID 매핑
|
||||
|
||||
console.log("🔄 그룹 복제 시작 (재귀적):", {
|
||||
sourceGroup: sourceGroup.group_name,
|
||||
|
|
@ -795,7 +836,7 @@ export default function CopyScreenModal({
|
|||
|
||||
// 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용
|
||||
const rootGroupName = useGroupBulkRename && groupFindText
|
||||
? transformName(sourceGroup.group_name)
|
||||
? transformName(sourceGroup.group_name, false, sourceGroup.company_code)
|
||||
: newGroupName.trim();
|
||||
|
||||
const newGroupResponse = await createScreenGroup({
|
||||
|
|
@ -818,14 +859,41 @@ export default function CopyScreenModal({
|
|||
if (groupCopyMode !== "folder_only") {
|
||||
const sourceScreensInfo = sourceGroup.screens || [];
|
||||
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData);
|
||||
// 화면 정보와 display_order를 함께 매핑
|
||||
// allScreens에서 못 찾으면 그룹의 screens 정보를 직접 사용 (다른 회사 폴더 복사 시)
|
||||
console.log(`🔍 루트 그룹 화면 매핑 시작: ${sourceScreensInfo.length}개 화면, allScreens: ${allScreens.length}개`);
|
||||
const screensWithOrder = sourceScreensInfo.map((s: any) => {
|
||||
const screenId = typeof s === 'object' ? s.screen_id : s;
|
||||
const displayOrder = typeof s === 'object' ? s.display_order : 0;
|
||||
const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN';
|
||||
const screenName = typeof s === 'object' ? s.screen_name : '';
|
||||
const tableName = typeof s === 'object' ? s.table_name : '';
|
||||
|
||||
// allScreens에서 먼저 찾고, 없으면 그룹의 screens 정보로 대체
|
||||
let screenData = allScreens.find((sc) => sc.screenId === screenId);
|
||||
const foundInAllScreens = !!screenData;
|
||||
|
||||
if (!screenData && screenId && screenName) {
|
||||
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
|
||||
console.log(` ⚠️ allScreens에서 못 찾음, 그룹 정보 사용: ${screenId} - ${screenName}`);
|
||||
screenData = {
|
||||
screenId: screenId,
|
||||
screenName: screenName,
|
||||
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
|
||||
tableName: tableName || '',
|
||||
description: '',
|
||||
companyCode: sourceGroup.company_code || '',
|
||||
} as any;
|
||||
} else if (screenData) {
|
||||
console.log(` ✅ allScreens에서 찾음: ${screenId} - ${screenData.screenName}`);
|
||||
} else {
|
||||
console.log(` ❌ 화면 정보 없음: screenId=${screenId}, screenName=${screenName}`);
|
||||
}
|
||||
return { screenId, displayOrder, screenRole, screenData };
|
||||
}).filter(item => item.screenData && item.screenId); // screenId가 유효한 것만
|
||||
|
||||
console.log(`🔍 매핑 완료: ${screensWithOrder.length}개 화면 복사 예정`);
|
||||
screensWithOrder.forEach(item => console.log(` - ${item.screenId}: ${item.screenData?.screenName}`));
|
||||
|
||||
// display_order 순으로 정렬
|
||||
screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0));
|
||||
|
|
@ -843,12 +911,13 @@ export default function CopyScreenModal({
|
|||
message: `화면 복제 중: ${screen.screenName}`
|
||||
});
|
||||
|
||||
console.log(`📄 화면 복제: ${screen.screenName} → ${newScreenCode}`);
|
||||
const transformedScreenName = transformName(screen.screenName, false, sourceGroup.company_code);
|
||||
console.log(`📄 화면 복제: ${screen.screenName} → ${transformedScreenName}`);
|
||||
|
||||
const result = await screenApi.copyScreenWithModals(screen.screenId, {
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
mainScreen: {
|
||||
screenName: transformName(screen.screenName), // 일괄 이름 변경 적용
|
||||
screenName: transformedScreenName, // 일괄 이름 변경 적용
|
||||
screenCode: newScreenCode,
|
||||
description: screen.description || "",
|
||||
},
|
||||
|
|
@ -856,14 +925,18 @@ export default function CopyScreenModal({
|
|||
});
|
||||
|
||||
if (result.mainScreen?.screenId) {
|
||||
// 원본 화면 ID -> 새 화면 ID 매핑 기록
|
||||
screenIdMap[screen.screenId] = result.mainScreen.screenId;
|
||||
|
||||
await addScreenToGroup({
|
||||
group_id: newRootGroup.id,
|
||||
screen_id: result.mainScreen.screenId,
|
||||
screen_role: screenRole || "MAIN",
|
||||
display_order: displayOrder, // 원본 정렬순서 유지
|
||||
target_company_code: finalCompanyCode, // 대상 회사 코드 전달
|
||||
});
|
||||
stats.screens++;
|
||||
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`);
|
||||
console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName} (${screen.screenId} → ${result.mainScreen.screenId})`);
|
||||
}
|
||||
} catch (screenError) {
|
||||
console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError);
|
||||
|
|
@ -886,11 +959,180 @@ export default function CopyScreenModal({
|
|||
screenCodes,
|
||||
codeIndex,
|
||||
stats,
|
||||
totalScreenCount
|
||||
totalScreenCount,
|
||||
screenIdMap // screenIdMap 전달
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 탭 컴포넌트의 screenId 참조 일괄 업데이트
|
||||
console.log("🔍 screenIdMap 상태:", screenIdMap, "키 개수:", Object.keys(screenIdMap).length);
|
||||
if (Object.keys(screenIdMap).length > 0) {
|
||||
console.log("🔗 탭 screenId 참조 업데이트 중...", screenIdMap);
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "탭 참조 업데이트 중..." });
|
||||
|
||||
const targetScreenIds = Object.values(screenIdMap);
|
||||
try {
|
||||
const updateResult = await updateTabScreenReferences(targetScreenIds, screenIdMap);
|
||||
console.log(`✅ 탭 screenId 참조 업데이트 완료: ${updateResult.updated}개 레이아웃`);
|
||||
} catch (tabUpdateError) {
|
||||
console.warn("탭 screenId 참조 업데이트 실패 (무시):", tabUpdateError);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 채번규칙 복제 옵션이 선택된 경우 (복제 → 메뉴 동기화 → 채번규칙 복제)
|
||||
if (copyNumberingRules) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." });
|
||||
console.log("📋 메뉴 동기화 시작 (채번규칙 복제 준비)...");
|
||||
|
||||
// 7-1. 메뉴 동기화 (화면 그룹 → 메뉴)
|
||||
const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", {
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (syncResponse.data?.success) {
|
||||
console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data);
|
||||
|
||||
// 7-2. 채번규칙 복제
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." });
|
||||
console.log("📋 채번규칙 복제 시작...");
|
||||
|
||||
const numberingResponse = await apiClient.post("/numbering-rules/copy-for-company", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (numberingResponse.data?.success) {
|
||||
console.log("✅ 채번규칙 복제 완료:", numberingResponse.data.data);
|
||||
toast.success(`채번규칙 ${numberingResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("채번규칙 복제 실패:", numberingResponse.data?.error);
|
||||
toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요.");
|
||||
}
|
||||
|
||||
// 7-3. 화면-메뉴 할당 복제 (screen_menu_assignments)
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." });
|
||||
console.log("📋 화면-메뉴 할당 복제 시작...");
|
||||
|
||||
const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
screenIdMap,
|
||||
});
|
||||
|
||||
if (menuAssignResponse.data?.success) {
|
||||
console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data);
|
||||
toast.success(`화면-메뉴 할당 ${menuAssignResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error);
|
||||
}
|
||||
} else {
|
||||
console.warn("메뉴 동기화 실패:", syncResponse.data?.error);
|
||||
toast.warning("메뉴 동기화에 실패했습니다. 채번규칙이 복제되지 않았습니다.");
|
||||
}
|
||||
} catch (numberingError) {
|
||||
console.error("채번규칙 복제 중 오류:", numberingError);
|
||||
toast.warning("채번규칙 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 코드 카테고리 + 코드 복제
|
||||
if (copyCodeCategory) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "코드 카테고리/코드 복제 중..." });
|
||||
console.log("📋 코드 카테고리/코드 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-code-category", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 코드 카테고리/코드 복제 완료:", response.data.data);
|
||||
toast.success(`코드 카테고리 ${response.data.data?.copiedCategories || 0}개, 코드 ${response.data.data?.copiedCodes || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("코드 카테고리/코드 복제 실패:", response.data?.error);
|
||||
toast.warning("코드 카테고리/코드 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리/코드 복제 중 오류:", error);
|
||||
toast.warning("코드 카테고리/코드 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 카테고리 매핑 + 값 복제
|
||||
if (copyCategoryMapping) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 매핑/값 복제 중..." });
|
||||
console.log("📋 카테고리 매핑/값 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-category-mapping", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 카테고리 매핑/값 복제 완료:", response.data.data);
|
||||
toast.success(`카테고리 매핑 ${response.data.data?.copiedMappings || 0}개, 값 ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("카테고리 매핑/값 복제 실패:", response.data?.error);
|
||||
toast.warning("카테고리 매핑/값 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 매핑/값 복제 중 오류:", error);
|
||||
toast.warning("카테고리 매핑/값 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 테이블 타입관리 입력타입 설정 복제
|
||||
if (copyTableTypeColumns) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." });
|
||||
console.log("📋 테이블 타입 컬럼 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-table-type-columns", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 테이블 타입 컬럼 복제 완료:", response.data.data);
|
||||
toast.success(`테이블 타입 컬럼 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("테이블 타입 컬럼 복제 실패:", response.data?.error);
|
||||
toast.warning("테이블 타입 컬럼 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 타입 컬럼 복제 중 오류:", error);
|
||||
toast.warning("테이블 타입 컬럼 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 연쇄관계 설정 복제
|
||||
if (copyCascadingRelation) {
|
||||
try {
|
||||
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "연쇄관계 설정 복제 중..." });
|
||||
console.log("📋 연쇄관계 설정 복제 시작...");
|
||||
|
||||
const response = await apiClient.post("/screen-management/copy-cascading-relation", {
|
||||
sourceCompanyCode: sourceGroup.company_code,
|
||||
targetCompanyCode: finalCompanyCode,
|
||||
});
|
||||
|
||||
if (response.data?.success) {
|
||||
console.log("✅ 연쇄관계 설정 복제 완료:", response.data.data);
|
||||
toast.success(`연쇄관계 설정 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
|
||||
} else {
|
||||
console.warn("연쇄관계 설정 복제 실패:", response.data?.error);
|
||||
toast.warning("연쇄관계 설정 복제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄관계 설정 복제 중 오류:", error);
|
||||
toast.warning("연쇄관계 설정 복제 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||
);
|
||||
|
|
@ -1045,6 +1287,89 @@ export default function CopyScreenModal({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 추가 복사 옵션 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm font-medium">추가 복사 옵션 (선택사항):</Label>
|
||||
|
||||
{/* 코드 카테고리 + 코드 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCodeCategory"
|
||||
checked={copyCodeCategory}
|
||||
onCheckedChange={(checked) => setCopyCodeCategory(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCodeCategory" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-muted-foreground" />
|
||||
코드 카테고리 + 코드 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 채번규칙 복제 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyNumberingRules"
|
||||
checked={copyNumberingRules}
|
||||
onCheckedChange={(checked) => setCopyNumberingRules(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyNumberingRules" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
채번 규칙 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 매핑 + 값 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCategoryMapping"
|
||||
checked={copyCategoryMapping}
|
||||
onCheckedChange={(checked) => setCopyCategoryMapping(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCategoryMapping" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Table className="h-4 w-4 text-muted-foreground" />
|
||||
카테고리 매핑 + 값 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 테이블 타입관리 입력타입 설정 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyTableTypeColumns"
|
||||
checked={copyTableTypeColumns}
|
||||
onCheckedChange={(checked) => setCopyTableTypeColumns(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyTableTypeColumns" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
테이블 타입관리 입력타입 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 연쇄관계 설정 복사 */}
|
||||
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
|
||||
<Checkbox
|
||||
id="copyCascadingRelation"
|
||||
checked={copyCascadingRelation}
|
||||
onCheckedChange={(checked) => setCopyCascadingRelation(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="copyCascadingRelation" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
연쇄관계 설정 복사
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 복사 항목 안내 */}
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<p className="text-xs font-medium mb-1">기본 복사 항목:</p>
|
||||
<ul className="text-[10px] sm:text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
</ul>
|
||||
<p className="text-[10px] text-muted-foreground mt-2 italic">
|
||||
* 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 새 그룹명 + 정렬 순서 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
|
|
|
|||
|
|
@ -185,16 +185,18 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
}
|
||||
}, [open, screenCode]);
|
||||
|
||||
// 테이블 선택은 선택 사항 - 컴포넌트별로 테이블을 설정할 수 있음
|
||||
const isValid = useMemo(() => {
|
||||
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
return baseValid && tableName.trim().length > 0;
|
||||
// 테이블 선택은 선택 사항 (비워두면 컴포넌트별로 테이블 설정)
|
||||
return baseValid;
|
||||
} else {
|
||||
// REST API: 연결 선택 필수
|
||||
return baseValid && selectedRestApiId !== null;
|
||||
}
|
||||
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
|
||||
}, [screenName, screenCode, dataSourceType, selectedRestApiId]);
|
||||
|
||||
// 테이블 필터링 (내부 DB용)
|
||||
const filteredTables = useMemo(() => {
|
||||
|
|
@ -230,8 +232,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
};
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
// 데이터베이스 소스
|
||||
createData.tableName = tableName.trim();
|
||||
// 데이터베이스 소스 - 테이블 선택은 선택 사항
|
||||
createData.tableName = tableName.trim() || null; // 비어있으면 null
|
||||
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
|
||||
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
|
||||
} else {
|
||||
|
|
@ -507,7 +509,10 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
|
||||
{dataSourceType === "database" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블 *</Label>
|
||||
<Label htmlFor="tableName">기본 테이블 (선택)</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
비워두면 화면 디자이너에서 컴포넌트별로 테이블을 설정할 수 있습니다.
|
||||
</p>
|
||||
<Select
|
||||
value={tableName}
|
||||
onValueChange={setTableName}
|
||||
|
|
@ -521,7 +526,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "(선택 사항) 기본 테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
{/* 검색 입력 필드 */}
|
||||
|
|
|
|||
|
|
@ -811,15 +811,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("[EditModal] 최종 저장 데이터:", dataToSave);
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
||||
const masterDataToSave: Record<string, any> = {};
|
||||
Object.entries(dataToSave).forEach(([key, value]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
masterDataToSave[key] = value;
|
||||
} else {
|
||||
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[EditModal] 최종 저장 데이터:", masterDataToSave);
|
||||
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId!,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: dataToSave,
|
||||
data: masterDataToSave,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
|
||||
|
||||
toast.success("데이터가 생성되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
|
|
|
|||
|
|
@ -384,13 +384,23 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
// valueCode -> {label, color} 매핑 생성
|
||||
// valueCode 및 valueId -> {label, color} 매핑 생성
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
mapping[item.valueCode] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
// valueCode로 매핑
|
||||
if (item.valueCode) {
|
||||
mapping[item.valueCode] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
});
|
||||
mappings[col.columnName] = mapping;
|
||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
|
||||
|
|
|
|||
|
|
@ -568,11 +568,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
||||
// 스타일 적용
|
||||
// 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리
|
||||
const isEntityJoin = (comp as any).isEntityJoin === true;
|
||||
const isReadonly = readonly || isEntityJoin;
|
||||
|
||||
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
|
||||
const compLangKey = (comp as any).langKey;
|
||||
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
|
||||
|
||||
const applyStyles = (element: React.ReactElement) => {
|
||||
if (!comp.style) return element;
|
||||
|
||||
|
|
@ -683,7 +690,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
|
||||
value={displayValue}
|
||||
onChange={isAutoInput ? undefined : handleInputChange}
|
||||
disabled={readonly || isAutoInput}
|
||||
disabled={isReadonly || isAutoInput}
|
||||
readOnly={isAutoInput}
|
||||
required={required}
|
||||
minLength={config?.minLength}
|
||||
|
|
@ -724,7 +731,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={finalPlaceholder}
|
||||
value={currentValue}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
min={config?.min}
|
||||
max={config?.max}
|
||||
|
|
@ -763,7 +770,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={finalPlaceholder}
|
||||
value={currentValue || config?.defaultValue || ""}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
minLength={config?.minLength}
|
||||
maxLength={config?.maxLength}
|
||||
|
|
@ -815,7 +822,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
value={currentValue}
|
||||
onChange={(value) => updateFormData(fieldName, value)}
|
||||
placeholder={finalPlaceholder}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -833,7 +840,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
value={currentValue}
|
||||
onChange={(value) => updateFormData(fieldName, value)}
|
||||
placeholder={finalPlaceholder}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -850,7 +857,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
<Select
|
||||
value={currentValue || config?.defaultValue || ""}
|
||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className="h-full w-full">
|
||||
|
|
@ -897,7 +904,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
id={fieldName}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
/>
|
||||
<label htmlFor={fieldName} className="text-sm">
|
||||
|
|
@ -943,7 +950,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
value=""
|
||||
checked={selectedValue === ""}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
|
@ -961,7 +968,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly || option.disabled}
|
||||
disabled={isReadonly || option.disabled}
|
||||
required={required}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
|
|
@ -1002,7 +1009,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={finalPlaceholder}
|
||||
value={currentValue || config?.defaultValue || ""}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
|
|
@ -1019,7 +1026,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
<Button
|
||||
variant="outline"
|
||||
className="h-full w-full justify-start text-left font-normal"
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
|
||||
|
|
@ -1062,7 +1069,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={finalPlaceholder}
|
||||
value={currentValue || config?.defaultValue || ""}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
|
|
@ -1246,7 +1253,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
type="file"
|
||||
data-field={fieldName}
|
||||
onChange={handleFileChange}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
multiple={config?.multiple}
|
||||
accept={config?.accept}
|
||||
|
|
@ -1354,7 +1361,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
<Select
|
||||
value={currentValue || ""}
|
||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger className="h-full w-full">
|
||||
|
|
@ -1648,10 +1655,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
||||
const masterDataWithUserInfo: Record<string, any> = {};
|
||||
Object.entries(dataWithUserInfo).forEach(([key, value]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
masterDataWithUserInfo[key] = value;
|
||||
} else {
|
||||
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
});
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
screenId: screenInfo.id,
|
||||
tableName: tableName,
|
||||
data: dataWithUserInfo,
|
||||
data: masterDataWithUserInfo,
|
||||
};
|
||||
|
||||
console.log("🚀 API 저장 요청:", saveData);
|
||||
|
|
@ -1659,6 +1676,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
const result = await dynamicFormApi.saveFormData(saveData);
|
||||
|
||||
if (result.success) {
|
||||
const masterRecordId = result.data?.id || formData.id;
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 repeaterSave 이벤트 발생:", { masterRecordId, tableName });
|
||||
|
||||
alert("저장되었습니다.");
|
||||
// console.log("✅ 저장 성공:", result.data);
|
||||
|
||||
|
|
@ -1915,11 +1947,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
return applyStyles(
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
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'
|
||||
? ""
|
||||
: "bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50"
|
||||
}`}
|
||||
style={{
|
||||
height: "100%",
|
||||
|
|
@ -1940,7 +1972,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
placeholder={placeholder || "입력하세요..."}
|
||||
value={currentValue}
|
||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||
disabled={readonly}
|
||||
disabled={isReadonly}
|
||||
required={required}
|
||||
className="w-full"
|
||||
style={{ height: "100%" }}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { FlowVisibilityConfig } from "@/types/control-management";
|
|||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -56,7 +57,7 @@ interface InteractiveScreenViewerProps {
|
|||
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||
originalData?: Record<string, any> | null;
|
||||
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabId?: string; // 부모 탭 ID
|
||||
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||
}
|
||||
|
||||
|
|
@ -334,6 +335,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 동적 대화형 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
// 조건부 표시 평가
|
||||
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
|
||||
|
||||
// 조건에 따라 숨김 처리
|
||||
if (!conditionalResult.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (isDataTableComponent(comp)) {
|
||||
return (
|
||||
|
|
@ -431,6 +440,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
});
|
||||
};
|
||||
|
||||
// 조건부 비활성화 적용
|
||||
const isConditionallyDisabled = conditionalResult.disabled;
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
if (widgetType) {
|
||||
try {
|
||||
|
|
@ -444,7 +456,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
onFormDataChange: handleFormDataChange,
|
||||
formData: formData, // 🆕 전체 formData 전달
|
||||
isInteractive: true,
|
||||
readonly: readonly,
|
||||
readonly: readonly || isConditionallyDisabled, // 조건부 비활성화 적용
|
||||
disabled: isConditionallyDisabled, // 조건부 비활성화 전달
|
||||
required: required,
|
||||
placeholder: placeholder,
|
||||
className: "w-full h-full",
|
||||
|
|
@ -470,7 +483,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
value={currentValue}
|
||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||
placeholder={`${widgetType} (렌더링 오류)`}
|
||||
disabled={readonly}
|
||||
disabled={readonly || isConditionallyDisabled}
|
||||
required={required}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
|
|
@ -486,7 +499,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
value={currentValue}
|
||||
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
||||
placeholder={placeholder || "입력하세요"}
|
||||
disabled={readonly}
|
||||
disabled={readonly || isConditionallyDisabled}
|
||||
required={required}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
|
|
@ -519,15 +532,40 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
|
||||
try {
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
||||
const masterFormData: Record<string, any> = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
// 배열 데이터는 리피터 데이터이므로 제외
|
||||
if (!Array.isArray(value)) {
|
||||
masterFormData[key] = value;
|
||||
} else {
|
||||
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
});
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
tableName: screenInfo.tableName,
|
||||
data: formData,
|
||||
data: masterFormData,
|
||||
};
|
||||
|
||||
// console.log("💾 저장 액션 실행:", saveData);
|
||||
const response = await dynamicFormApi.saveData(saveData);
|
||||
|
||||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
||||
mainFormData: formData,
|
||||
tableName: screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
|
|
@ -604,7 +642,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
try {
|
||||
const { default: apiClient } = await import("@/lib/api/client");
|
||||
const columnsResponse = await apiClient.get(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
|
||||
);
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||||
|
|
@ -694,18 +732,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}
|
||||
|
||||
// 시스템 컬럼 제외
|
||||
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
|
||||
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
|
||||
if (systemColumns.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||||
if (key.endsWith('_label') || key.endsWith('_name')) {
|
||||
if (key.endsWith("_label") || key.endsWith("_name")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 값이 있으면 자동 추가
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
if (val !== undefined && val !== null && val !== "") {
|
||||
insertData[key] = val;
|
||||
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||||
}
|
||||
|
|
@ -736,14 +774,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
console.log("📍 중복 체크 조건:", searchConditions);
|
||||
|
||||
// 기존 데이터 조회
|
||||
const checkResponse = await apiClient.post(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
search: searchConditions,
|
||||
}
|
||||
);
|
||||
const checkResponse = await apiClient.post(`/table-management/tables/${quickInsertConfig.targetTable}/data`, {
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
search: searchConditions,
|
||||
});
|
||||
|
||||
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||||
|
||||
|
|
@ -765,7 +800,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||||
insertData
|
||||
insertData,
|
||||
);
|
||||
|
||||
if (response.data?.success) {
|
||||
|
|
@ -1006,7 +1041,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
{popupScreen && (
|
||||
<Dialog open={!!popupScreen} onOpenChange={() => setPopupScreen(null)}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 max-w-none"
|
||||
className="max-w-none overflow-hidden p-0"
|
||||
style={{
|
||||
width: popupScreen.size === "small" ? "600px" : popupScreen.size === "large" ? "1400px" : "1000px",
|
||||
height: "800px",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ interface RealtimePreviewProps {
|
|||
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
|
||||
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
|
||||
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
|
||||
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
|
||||
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||
|
||||
// 버튼 액션을 위한 props
|
||||
screenId?: number;
|
||||
|
|
@ -65,6 +69,9 @@ interface RealtimePreviewProps {
|
|||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
|
||||
// 🆕 조건부 비활성화 상태
|
||||
conditionalDisabled?: boolean;
|
||||
}
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
|
|
@ -94,7 +101,7 @@ const getWidgetIcon = (widgetType: WebType | undefined): React.ReactNode => {
|
|||
return iconMap[widgetType] || <Type className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
component,
|
||||
isSelected = false,
|
||||
isDesignMode = true, // 기본값은 편집 모드
|
||||
|
|
@ -129,6 +136,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
formData,
|
||||
onFormDataChange,
|
||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
conditionalDisabled, // 🆕 조건부 비활성화 상태
|
||||
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
||||
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||
onResize, // 🆕 리사이즈 콜백
|
||||
}) => {
|
||||
// 🆕 화면 다국어 컨텍스트
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
|
|
@ -137,6 +149,102 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const lastUpdatedHeight = React.useRef<number | null>(null);
|
||||
|
||||
// 🆕 리사이즈 상태
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const [resizeSize, setResizeSize] = React.useState<{ width: number; height: number } | null>(null);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
|
||||
// 🆕 size가 업데이트되면 resizeSize 초기화 (레이아웃 상태가 props에 반영되었음)
|
||||
React.useEffect(() => {
|
||||
if (resizeSize && !isResizing) {
|
||||
// component.size가 resizeSize와 같아지면 resizeSize 초기화
|
||||
if (component.size?.width === resizeSize.width && component.size?.height === resizeSize.height) {
|
||||
setResizeSize(null);
|
||||
}
|
||||
}
|
||||
}, [component.size?.width, component.size?.height, resizeSize, isResizing]);
|
||||
|
||||
// 10px 단위 스냅 함수
|
||||
const snapTo10 = (value: number) => Math.round(value / 10) * 10;
|
||||
|
||||
// 🆕 리사이즈 핸들러
|
||||
const handleResizeStart = React.useCallback(
|
||||
(e: React.MouseEvent, direction: "e" | "s" | "se") => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const startMouseX = e.clientX;
|
||||
const startMouseY = e.clientY;
|
||||
const startWidth = component.size?.width || 200;
|
||||
const startHeight = component.size?.height || 100;
|
||||
|
||||
setIsResizing(true);
|
||||
setResizeSize({ width: startWidth, height: startHeight });
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const deltaX = moveEvent.clientX - startMouseX;
|
||||
const deltaY = moveEvent.clientY - startMouseY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
if (direction === "e" || direction === "se") {
|
||||
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
|
||||
}
|
||||
if (direction === "s" || direction === "se") {
|
||||
newHeight = snapTo10(Math.max(20, startHeight + deltaY));
|
||||
}
|
||||
|
||||
setResizeSize({ width: newWidth, height: newHeight });
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
|
||||
const deltaX = upEvent.clientX - startMouseX;
|
||||
const deltaY = upEvent.clientY - startMouseY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
|
||||
if (direction === "e" || direction === "se") {
|
||||
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
|
||||
}
|
||||
if (direction === "s" || direction === "se") {
|
||||
newHeight = snapTo10(Math.max(20, startHeight + deltaY));
|
||||
}
|
||||
|
||||
// 🆕 리사이즈 상태는 유지한 채로 크기 변경 콜백 호출
|
||||
// resizeSize는 null로 설정하지 않고 마지막 크기 유지
|
||||
// (component.size가 업데이트되면 자연스럽게 올바른 크기 표시)
|
||||
|
||||
// 🆕 크기 변경 콜백 호출하여 레이아웃 상태 업데이트
|
||||
if (onResize) {
|
||||
onResize(component.id, { width: newWidth, height: newHeight });
|
||||
}
|
||||
|
||||
// 🆕 리사이즈 플래그만 해제 (resizeSize는 마지막 크기 유지)
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[component.id, component.size, onResize]
|
||||
);
|
||||
|
||||
// 플로우 위젯의 실제 높이 측정
|
||||
React.useEffect(() => {
|
||||
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
||||
|
|
@ -239,18 +347,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return `${actualHeight}px`;
|
||||
}
|
||||
|
||||
// 1순위: style.height가 있으면 우선 사용 (문자열 그대로 또는 숫자+px)
|
||||
// 🆕 1순위: size.height가 있으면 우선 사용 (레이아웃에서 관리되는 실제 크기)
|
||||
// size는 레이아웃 상태에서 직접 관리되며 리사이즈로 변경됨
|
||||
if (size?.height && size.height > 0) {
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size.height, 200)}px`;
|
||||
}
|
||||
return `${size.height}px`;
|
||||
}
|
||||
|
||||
// 2순위: componentStyle.height (컴포넌트 정의에서 온 기본 스타일)
|
||||
if (componentStyle?.height) {
|
||||
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
|
||||
}
|
||||
|
||||
// 2순위: size.height (픽셀)
|
||||
// 3순위: 기본값
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
return `${Math.max(size?.height || 200, 200)}px`;
|
||||
return "200px";
|
||||
}
|
||||
|
||||
// size.height가 있으면 그대로 사용, 없으면 최소 10px
|
||||
return `${size?.height || 10}px`;
|
||||
// 기본 높이
|
||||
return "10px";
|
||||
};
|
||||
|
||||
// layout 타입 컴포넌트인지 확인
|
||||
|
|
@ -395,16 +512,22 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
|
||||
// 🆕 리사이즈 크기가 있으면 우선 사용
|
||||
// (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정)
|
||||
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
|
||||
transition:
|
||||
isResizing ? "none" :
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
};
|
||||
|
||||
|
|
@ -513,6 +636,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
sortOrder={sortOrder}
|
||||
columnOrder={columnOrder}
|
||||
onHeightChange={onHeightChange}
|
||||
conditionalDisabled={conditionalDisabled}
|
||||
onUpdateComponent={onUpdateComponent}
|
||||
onSelectTabComponent={onSelectTabComponent}
|
||||
selectedTabComponentId={selectedTabComponentId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -532,10 +659,37 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 리사이즈 가장자리 영역 - 선택된 컴포넌트 + 디자인 모드에서만 표시 */}
|
||||
{isSelected && isDesignMode && onResize && (
|
||||
<>
|
||||
{/* 오른쪽 가장자리 (너비 조절) */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize z-20 hover:bg-primary/10"
|
||||
onMouseDown={(e) => handleResizeStart(e, "e")}
|
||||
/>
|
||||
{/* 아래 가장자리 (높이 조절) */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize z-20 hover:bg-primary/10"
|
||||
onMouseDown={(e) => handleResizeStart(e, "s")}
|
||||
/>
|
||||
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-3 h-3 cursor-nwse-resize z-30 hover:bg-primary/20"
|
||||
onMouseDown={(e) => handleResizeStart(e, "se")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// React.memo로 래핑하여 불필요한 리렌더링 방지
|
||||
export const RealtimePreviewDynamic = React.memo(RealtimePreviewDynamicComponent);
|
||||
|
||||
// displayName 설정 (디버깅용)
|
||||
RealtimePreviewDynamic.displayName = "RealtimePreviewDynamic";
|
||||
|
||||
// 기존 RealtimePreview와의 호환성을 위한 export
|
||||
export { RealtimePreviewDynamic as RealtimePreview };
|
||||
export default RealtimePreviewDynamic;
|
||||
|
|
|
|||
|
|
@ -206,13 +206,23 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
company_code: dataToSave.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||||
};
|
||||
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
||||
const masterDataWithUserInfo: Record<string, any> = {};
|
||||
Object.entries(dataWithUserInfo).forEach(([key, value]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
masterDataWithUserInfo[key] = value;
|
||||
} else {
|
||||
console.log(`🔄 [SaveModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블명 결정
|
||||
const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
screenId: screenId,
|
||||
tableName: tableName,
|
||||
data: dataWithUserInfo,
|
||||
data: masterDataWithUserInfo,
|
||||
};
|
||||
|
||||
console.log("💾 저장 요청 데이터:", saveData);
|
||||
|
|
@ -221,6 +231,21 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
const result = await dynamicFormApi.saveFormData(saveData);
|
||||
|
||||
if (result.success) {
|
||||
const masterRecordId = result.data?.id || dataToSave.id;
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: dataToSave,
|
||||
tableName: tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 [SaveModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName });
|
||||
|
||||
// ✅ 저장 성공
|
||||
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,13 +4,24 @@ import { useState, useEffect } from "react";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Palette, Type, Square } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Palette, Type, Square, ChevronDown } from "lucide-react";
|
||||
import { ComponentStyle } from "@/types/screen";
|
||||
import { ColorPickerWithTransparent } from "./common/ColorPickerWithTransparent";
|
||||
|
||||
interface StyleEditorProps {
|
||||
style: ComponentStyle;
|
||||
onStyleChange: (style: ComponentStyle) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function StyleEditor({ style, onStyleChange, className }: StyleEditorProps) {
|
||||
const [localStyle, setLocalStyle] = useState<ComponentStyle>(style || {});
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||
border: false,
|
||||
background: false,
|
||||
text: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStyle(style || {});
|
||||
|
|
@ -22,232 +33,255 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onStyleChange(newStyle);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 p-3 ${className}`}>
|
||||
{/* 테두리 섹션 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">테두리</h3>
|
||||
</div>
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderWidth" className="text-xs font-medium">
|
||||
두께
|
||||
</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderStyle" className="text-xs font-medium">
|
||||
스타일
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid" className="text-xs">
|
||||
실선
|
||||
</SelectItem>
|
||||
<SelectItem value="dashed" className="text-xs">
|
||||
파선
|
||||
</SelectItem>
|
||||
<SelectItem value="dotted" className="text-xs">
|
||||
점선
|
||||
</SelectItem>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
없음
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
const toggleSection = (section: string) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<ColorPickerWithTransparent
|
||||
id="borderColor"
|
||||
value={localStyle.borderColor}
|
||||
onChange={(value) => handleStyleChange("borderColor", value)}
|
||||
defaultColor="#e5e7eb"
|
||||
placeholder="#e5e7eb"
|
||||
/>
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{/* 테두리 섹션 */}
|
||||
<Collapsible open={openSections.border} onOpenChange={() => toggleSection("border")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Square className="text-primary h-3 w-3" />
|
||||
<span className="text-xs font-medium">테두리</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.border ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderWidth" className="text-xs font-medium">
|
||||
두께
|
||||
</Label>
|
||||
<Input
|
||||
id="borderWidth"
|
||||
type="text"
|
||||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderStyle" className="text-xs font-medium">
|
||||
스타일
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid" className="text-xs">
|
||||
실선
|
||||
</SelectItem>
|
||||
<SelectItem value="dashed" className="text-xs">
|
||||
파선
|
||||
</SelectItem>
|
||||
<SelectItem value="dotted" className="text-xs">
|
||||
점선
|
||||
</SelectItem>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
없음
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
||||
모서리
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<ColorPickerWithTransparent
|
||||
id="borderColor"
|
||||
value={localStyle.borderColor}
|
||||
onChange={(value) => handleStyleChange("borderColor", value)}
|
||||
defaultColor="#e5e7eb"
|
||||
placeholder="#e5e7eb"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
||||
모서리
|
||||
</Label>
|
||||
<Input
|
||||
id="borderRadius"
|
||||
type="text"
|
||||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* 배경 섹션 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">배경</h3>
|
||||
</div>
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<ColorPickerWithTransparent
|
||||
id="backgroundColor"
|
||||
value={localStyle.backgroundColor}
|
||||
onChange={(value) => handleStyleChange("backgroundColor", value)}
|
||||
defaultColor="#ffffff"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
<Collapsible open={openSections.background} onOpenChange={() => toggleSection("background")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Palette className="text-primary h-3 w-3" />
|
||||
<span className="text-xs font-medium">배경</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||
배경 이미지 (CSS)
|
||||
</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
위젯 배경 꾸미기용 (고급 사용자 전용)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 섹션 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">텍스트</h3>
|
||||
</div>
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.background ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="color" className="text-xs font-medium">
|
||||
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<ColorPickerWithTransparent
|
||||
id="color"
|
||||
value={localStyle.color}
|
||||
onChange={(value) => handleStyleChange("color", value)}
|
||||
defaultColor="#000000"
|
||||
placeholder="#000000"
|
||||
id="backgroundColor"
|
||||
value={localStyle.backgroundColor}
|
||||
onChange={(value) => handleStyleChange("backgroundColor", value)}
|
||||
defaultColor="#ffffff"
|
||||
placeholder="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontSize" className="text-xs font-medium">
|
||||
크기
|
||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||
배경 이미지 (CSS)
|
||||
</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
id="backgroundImage"
|
||||
type="text"
|
||||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">위젯 배경 꾸미기용 (고급 사용자 전용)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontWeight" className="text-xs font-medium">
|
||||
굵기
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal" className="text-xs">
|
||||
보통
|
||||
</SelectItem>
|
||||
<SelectItem value="bold" className="text-xs">
|
||||
굵게
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-xs">
|
||||
100
|
||||
</SelectItem>
|
||||
<SelectItem value="400" className="text-xs">
|
||||
400
|
||||
</SelectItem>
|
||||
<SelectItem value="500" className="text-xs">
|
||||
500
|
||||
</SelectItem>
|
||||
<SelectItem value="600" className="text-xs">
|
||||
600
|
||||
</SelectItem>
|
||||
<SelectItem value="700" className="text-xs">
|
||||
700
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 텍스트 섹션 */}
|
||||
<Collapsible open={openSections.text} onOpenChange={() => toggleSection("text")}>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md bg-gray-50 px-2 py-1.5 hover:bg-gray-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Type className="text-primary h-3 w-3" />
|
||||
<span className="text-xs font-medium">텍스트</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-gray-500 transition-transform ${openSections.text ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="color" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<ColorPickerWithTransparent
|
||||
id="color"
|
||||
value={localStyle.color}
|
||||
onChange={(value) => handleStyleChange("color", value)}
|
||||
defaultColor="#000000"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontSize" className="text-xs font-medium">
|
||||
크기
|
||||
</Label>
|
||||
<Input
|
||||
id="fontSize"
|
||||
type="text"
|
||||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="textAlign" className="text-xs font-medium">
|
||||
정렬
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left" className="text-xs">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="text-xs">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="justify" className="text-xs">
|
||||
양쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontWeight" className="text-xs font-medium">
|
||||
굵기
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal" className="text-xs">
|
||||
보통
|
||||
</SelectItem>
|
||||
<SelectItem value="bold" className="text-xs">
|
||||
굵게
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-xs">
|
||||
100
|
||||
</SelectItem>
|
||||
<SelectItem value="400" className="text-xs">
|
||||
400
|
||||
</SelectItem>
|
||||
<SelectItem value="500" className="text-xs">
|
||||
500
|
||||
</SelectItem>
|
||||
<SelectItem value="600" className="text-xs">
|
||||
600
|
||||
</SelectItem>
|
||||
<SelectItem value="700" className="text-xs">
|
||||
700
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="textAlign" className="text-xs font-medium">
|
||||
정렬
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left" className="text-xs">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="text-xs">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="justify" className="text-xs">
|
||||
양쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -48,6 +49,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
currentTableName, // 현재 화면의 테이블명
|
||||
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
|
||||
}) => {
|
||||
// 🔧 component가 없는 경우 방어 처리
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
컴포넌트 정보를 불러올 수 없습니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||
const config = component.componentConfig || {};
|
||||
const currentAction = component.componentConfig?.action || {};
|
||||
|
|
@ -55,6 +65,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
// 로컬 상태 관리 (실시간 입력 반영)
|
||||
const [localInputs, setLocalInputs] = useState({
|
||||
text: config.text !== undefined ? config.text : "버튼",
|
||||
actionType: String(config.action?.type || "save"),
|
||||
modalTitle: String(config.action?.modalTitle || ""),
|
||||
modalDescription: String(config.action?.modalDescription || ""),
|
||||
editModalTitle: String(config.action?.editModalTitle || ""),
|
||||
|
|
@ -106,6 +117,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
|
||||
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🆕 modal 액션용 필드 매핑 상태
|
||||
const [modalActionSourceTable, setModalActionSourceTable] = useState<string | null>(null);
|
||||
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
|
||||
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]);
|
||||
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
|
||||
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [modalFieldMappingTargetSearch, setModalFieldMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -114,13 +136,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
// "flow-widget" 체크
|
||||
const isFlow = compType === "flow-widget" || compType?.toLowerCase().includes("flow");
|
||||
|
||||
if (isFlow) {
|
||||
console.log("✅ 플로우 위젯 발견!", { id: comp.id, componentType: comp.componentType });
|
||||
}
|
||||
return isFlow;
|
||||
});
|
||||
console.log("🎯 플로우 위젯 존재 여부:", found);
|
||||
return found;
|
||||
}, [allComponents]);
|
||||
|
||||
|
|
@ -131,6 +148,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
setLocalInputs({
|
||||
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
|
||||
actionType: String(latestAction.type || "save"),
|
||||
modalTitle: String(latestAction.modalTitle || ""),
|
||||
modalDescription: String(latestAction.modalDescription || ""),
|
||||
editModalTitle: String(latestAction.editModalTitle || ""),
|
||||
|
|
@ -147,7 +165,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setTitleBlocks([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [component.id]);
|
||||
}, [component.id, component.componentConfig?.action?.type]);
|
||||
|
||||
// 🆕 제목 블록 핸들러
|
||||
const addTextBlock = () => {
|
||||
|
|
@ -230,7 +248,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
label: table.displayName || table.tableName,
|
||||
}));
|
||||
setAvailableTables(tables);
|
||||
console.log("✅ 전체 테이블 목록 로드 성공:", tables.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
|
|
@ -332,6 +349,123 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
|
||||
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
|
||||
useEffect(() => {
|
||||
const actionType = config.action?.type;
|
||||
if (actionType !== "modal") return;
|
||||
|
||||
const autoDetect = config.action?.autoDetectDataSource;
|
||||
if (!autoDetect) {
|
||||
// 데이터 전달이 비활성화되면 상태 초기화
|
||||
setModalActionSourceTable(null);
|
||||
setModalActionTargetTable(null);
|
||||
setModalActionSourceColumns([]);
|
||||
setModalActionTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetScreenId = config.action?.targetScreenId;
|
||||
if (!targetScreenId) return;
|
||||
|
||||
const loadModalActionMappingData = async () => {
|
||||
// 1. 소스 테이블 감지 (현재 화면)
|
||||
let sourceTableName: string | null = currentTableName || null;
|
||||
|
||||
// allComponents에서 분할패널/테이블리스트/통합목록 감지
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.componentType || (comp as any).componentConfig?.type;
|
||||
const compConfig = (comp as any).componentConfig || {};
|
||||
|
||||
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
|
||||
sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = compConfig.tableName || compConfig.selectedTable || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
if (compType === "unified-list") {
|
||||
sourceTableName = compConfig.dataSource?.table || compConfig.tableName || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
}
|
||||
|
||||
setModalActionSourceTable(sourceTableName);
|
||||
|
||||
// 2. 대상 화면의 테이블 조회
|
||||
let targetTableName: string | null = null;
|
||||
try {
|
||||
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
|
||||
if (screenResponse.data.success && screenResponse.data.data) {
|
||||
targetTableName = screenResponse.data.data.tableName || null;
|
||||
} else if (screenResponse.data?.tableName) {
|
||||
// 직접 데이터 반환 형식인 경우
|
||||
targetTableName = screenResponse.data.tableName || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("대상 화면 정보 로드 실패:", error);
|
||||
}
|
||||
|
||||
setModalActionTargetTable(targetTableName);
|
||||
|
||||
// 3. 소스 테이블 컬럼 로드
|
||||
if (sourceTableName) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalActionSourceColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 대상 테이블 컬럼 로드
|
||||
if (targetTableName) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setModalActionTargetColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("대상 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
|
||||
const existingMappings = config.action?.fieldMappings || [];
|
||||
if (existingMappings.length > 0) {
|
||||
setModalActionFieldMappings(existingMappings);
|
||||
} else if (sourceTableName && targetTableName && sourceTableName === targetTableName) {
|
||||
// 테이블이 같으면 자동 매핑 (동일 컬럼명)
|
||||
setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑
|
||||
}
|
||||
};
|
||||
|
||||
loadModalActionMappingData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
|
||||
|
||||
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
|
||||
useEffect(() => {
|
||||
if (!currentTableName) return;
|
||||
|
|
@ -350,7 +484,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setCurrentTableColumns(columns);
|
||||
console.log(`✅ 현재 테이블 ${currentTableName} 컬럼 로드 성공:`, columns.length, "개");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -640,14 +773,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
||||
// component,
|
||||
// config,
|
||||
// action: config.action,
|
||||
// actionType: config.action?.type,
|
||||
// screensCount: screens.length,
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -667,9 +792,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<div>
|
||||
<Label htmlFor="button-action">버튼 액션</Label>
|
||||
<Select
|
||||
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
|
||||
value={component.componentConfig?.action?.type || "save"}
|
||||
key={`action-${component.id}`}
|
||||
value={localInputs.actionType}
|
||||
onValueChange={(value) => {
|
||||
// 🔥 로컬 상태 먼저 업데이트
|
||||
setLocalInputs((prev) => ({ ...prev, actionType: value }));
|
||||
// 🔥 action.type 업데이트
|
||||
onUpdateProperty("componentConfig.action.type", value);
|
||||
|
||||
|
|
@ -684,30 +811,40 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectValue placeholder="버튼 액션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 핵심 액션 */}
|
||||
<SelectItem value="save">저장</SelectItem>
|
||||
<SelectItem value="delete">삭제</SelectItem>
|
||||
<SelectItem value="edit">편집</SelectItem>
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기</SelectItem>
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||
|
||||
{/* 엑셀 관련 */}
|
||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
|
||||
{/* 고급 기능 */}
|
||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
|
||||
{/* 특수 기능 (필요 시 사용) */}
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
|
||||
<SelectItem value="operation_control">운행알림 및 종료</SelectItem>
|
||||
|
||||
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
<SelectItem value="empty_vehicle">공차등록</SelectItem>
|
||||
*/}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 모달 열기 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "modal" && (
|
||||
{localInputs.actionType === "modal" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">모달 설정</h4>
|
||||
|
||||
|
|
@ -768,8 +905,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
className="text-xs"
|
||||
className="h-6 w-full justify-between px-2 py-0 text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -829,39 +965,225 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 선택된 데이터 전달 옵션 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-detect-data-source"
|
||||
checked={component.componentConfig?.action?.autoDetectDataSource === true}
|
||||
onCheckedChange={(checked) => {
|
||||
onUpdateProperty("componentConfig.action.autoDetectDataSource", checked);
|
||||
if (!checked) {
|
||||
// 체크 해제 시 필드 매핑도 초기화
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", []);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="auto-detect-data-source" className="text-sm cursor-pointer">
|
||||
선택된 데이터 전달
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
TableList/SplitPanel에서 선택된 데이터를 모달에 자동으로 전달합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
|
||||
{component.componentConfig?.action?.autoDetectDataSource === true && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border bg-background p-3">
|
||||
{/* 테이블 정보 표시 */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">소스:</span>
|
||||
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">대상:</span>
|
||||
<span className="font-medium">{modalActionTargetTable || "감지 중..."}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블이 같으면 자동 매핑 안내 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
|
||||
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
|
||||
동일한 테이블입니다. 컬럼명이 같은 필드는 자동으로 매핑됩니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
|
||||
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
컬럼명이 다른 경우 매핑을 추가하세요. 매핑이 없으면 동일 컬럼명만 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{/* 소스 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingSourceOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.sourceField
|
||||
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
|
||||
: "소스 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingSourceSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionSourceColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], sourceField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 대상 필드 선택 */}
|
||||
<Popover
|
||||
open={modalFieldMappingTargetOpen[index] || false}
|
||||
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
|
||||
{mapping.targetField
|
||||
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
|
||||
: "대상 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
value={modalFieldMappingTargetSearch[index] || ""}
|
||||
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modalActionTargetColumns
|
||||
.filter((col) =>
|
||||
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
|
||||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
|
||||
)
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
|
||||
newMappings[index] = { ...newMappings[index], targetField: col.name };
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{col.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
|
||||
setModalActionFieldMappings(newMappings);
|
||||
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
|
||||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
|
||||
{component.componentConfig?.action?.type === "openModalWithData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
|
||||
<h4 className="text-foreground text-sm font-medium">데이터 전달 + 모달 설정</h4>
|
||||
<p className="text-muted-foreground text-xs">TableList에서 선택된 데이터를 다음 모달로 전달합니다</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data-source-id">
|
||||
데이터 소스 ID <span className="text-primary">(선택사항)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="data-source-id"
|
||||
placeholder="비워두면 자동으로 감지됩니다"
|
||||
value={component.componentConfig?.action?.dataSourceId || ""}
|
||||
onChange={(e) => {
|
||||
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-primary mt-1 text-xs font-medium">
|
||||
✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
• 자동 감지: 현재 화면의 TableList 선택 데이터
|
||||
<br />
|
||||
• 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달
|
||||
<br />
|
||||
• 다음 화면에서 tableName으로 바로 사용 가능
|
||||
<br />• 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
|
||||
<h4 className="text-sm font-medium text-foreground">데이터 전달 + 모달 설정</h4>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
이 옵션은 "모달 열기" 액션으로 통합되었습니다. 새 개발에서는 "모달 열기" + "선택된 데이터 전달"을 사용하세요.
|
||||
</p>
|
||||
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1416,7 +1738,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 수정 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
||||
{localInputs.actionType === "edit" && (
|
||||
<div className="bg-success/10 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">수정 설정</h4>
|
||||
|
||||
|
|
@ -1673,7 +1995,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 복사 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "copy" && (
|
||||
{localInputs.actionType === "copy" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">복사 설정 (품목코드 자동 초기화)</h4>
|
||||
|
||||
|
|
@ -1828,7 +2150,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 테이블 이력 보기 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
|
||||
{localInputs.actionType === "view_table_history" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<Label>
|
||||
|
|
@ -1889,7 +2211,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 페이지 이동 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "navigate" && (
|
||||
{localInputs.actionType === "navigate" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">페이지 이동 설정</h4>
|
||||
|
||||
|
|
@ -1985,7 +2307,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 엑셀 다운로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_download" && (
|
||||
{localInputs.actionType === "excel_download" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">엑셀 다운로드 설정</h4>
|
||||
|
||||
|
|
@ -2024,7 +2346,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 엑셀 업로드 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "excel_upload" && (
|
||||
{localInputs.actionType === "excel_upload" && (
|
||||
<ExcelUploadConfigSection
|
||||
config={config}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
|
|
@ -2034,7 +2356,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 바코드 스캔 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "barcode_scan" && (
|
||||
{localInputs.actionType === "barcode_scan" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">📷 바코드 스캔 설정</h4>
|
||||
|
||||
|
|
@ -2081,7 +2403,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 코드 병합 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "code_merge" && (
|
||||
{localInputs.actionType === "code_merge" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">🔀 코드 병합 설정</h4>
|
||||
|
||||
|
|
@ -2128,14 +2450,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
|
||||
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
|
||||
{/* {localInputs.actionType === "empty_vehicle" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
... 공차등록 설정 UI 생략 ...
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* 운행알림 및 종료 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "operation_control" && (
|
||||
{localInputs.actionType === "operation_control" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">🚗 운행알림 및 종료 설정</h4>
|
||||
|
||||
|
|
@ -2576,7 +2898,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 데이터 전달 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "transferData" && (
|
||||
{localInputs.actionType === "transferData" && (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
<h4 className="text-foreground text-sm font-medium">📦 데이터 전달 설정</h4>
|
||||
|
||||
|
|
@ -3286,7 +3608,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 제어 기능 섹션 - 엑셀 업로드가 아닐 때만 표시 */}
|
||||
{(component.componentConfig?.action?.type || "save") !== "excel_upload" && (
|
||||
{localInputs.actionType !== "excel_upload" && (
|
||||
<div className="border-border mt-8 border-t pt-6">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,70 +5,59 @@ import { Label } from "@/components/ui/label";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Move,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TabItem, TabsComponent } from "@/types/screen-management";
|
||||
import type { TabItem, TabInlineComponent } from "@/types/screen-management";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
interface TabsConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
}
|
||||
|
||||
interface ScreenInfo {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localTabs, setLocalTabs] = useState<TabItem[]>(config.tabs || []);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// API 클라이언트 동적 import (named export 사용)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// 전체 화면 목록 조회 (페이징 사이즈 크게)
|
||||
const response = await apiClient.get("/screen-management/screens", {
|
||||
params: { size: 1000 }
|
||||
});
|
||||
|
||||
console.log("화면 목록 조회 성공:", response.data);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
setScreens(response.data.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load screens:", error);
|
||||
console.error("Error response:", error.response?.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화 (초기화만, 입력 중에는 동기화하지 않음)
|
||||
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
|
||||
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 사용자가 입력 중이 아닐 때만 동기화
|
||||
if (!isUserEditing) {
|
||||
setLocalTabs(config.tabs || []);
|
||||
}
|
||||
}, [config.tabs, isUserEditing]);
|
||||
|
||||
// 탭 확장/축소 토글
|
||||
const toggleTabExpand = (tabId: string) => {
|
||||
setExpandedTabs((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(tabId)) {
|
||||
newSet.delete(tabId);
|
||||
} else {
|
||||
newSet.add(tabId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 탭 추가
|
||||
const handleAddTab = () => {
|
||||
const newTab: TabItem = {
|
||||
|
|
@ -76,11 +65,15 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
label: `새 탭 ${localTabs.length + 1}`,
|
||||
order: localTabs.length,
|
||||
disabled: false,
|
||||
components: [],
|
||||
};
|
||||
|
||||
const updatedTabs = [...localTabs, newTab];
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
|
||||
// 새 탭 자동 확장
|
||||
setExpandedTabs((prev) => new Set([...prev, newTab.id]));
|
||||
};
|
||||
|
||||
// 탭 제거
|
||||
|
|
@ -93,27 +86,23 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
// 탭 라벨 변경 (입력 중)
|
||||
const handleLabelChange = (tabId: string, label: string) => {
|
||||
setIsUserEditing(true);
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab));
|
||||
const updatedTabs = localTabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, label } : tab
|
||||
);
|
||||
setLocalTabs(updatedTabs);
|
||||
// onChange는 onBlur에서 호출
|
||||
};
|
||||
|
||||
// 탭 라벨 변경 완료 (포커스 아웃 시)
|
||||
// 탭 라벨 변경 완료
|
||||
const handleLabelBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onChange({ ...config, tabs: localTabs });
|
||||
};
|
||||
|
||||
// 탭 화면 선택
|
||||
const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => {
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab));
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 탭 비활성화 토글
|
||||
const handleDisabledToggle = (tabId: string, disabled: boolean) => {
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab));
|
||||
const updatedTabs = localTabs.map((tab) =>
|
||||
tab.id === tabId ? { ...tab, disabled } : tab
|
||||
);
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
|
@ -130,14 +119,68 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
|
||||
const newTabs = [...localTabs];
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
[newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]];
|
||||
[newTabs[index], newTabs[targetIndex]] = [
|
||||
newTabs[targetIndex],
|
||||
newTabs[index],
|
||||
];
|
||||
|
||||
// order 값 재조정
|
||||
const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx }));
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 컴포넌트 제거
|
||||
const handleRemoveComponent = (tabId: string, componentId: string) => {
|
||||
const updatedTabs = localTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).filter(
|
||||
(comp) => comp.id !== componentId
|
||||
),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 컴포넌트 위치 변경
|
||||
const handleComponentPositionChange = (
|
||||
tabId: string,
|
||||
componentId: string,
|
||||
field: "x" | "y" | "width" | "height",
|
||||
value: number
|
||||
) => {
|
||||
const updatedTabs = localTabs.map((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((comp) => {
|
||||
if (comp.id === componentId) {
|
||||
if (field === "x" || field === "y") {
|
||||
return {
|
||||
...comp,
|
||||
position: { ...comp.position, [field]: value },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...comp,
|
||||
size: { ...comp.size, [field]: value },
|
||||
};
|
||||
}
|
||||
}
|
||||
return comp;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
});
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
|
|
@ -193,7 +236,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
</div>
|
||||
<Switch
|
||||
checked={config.persistSelection || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, persistSelection: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -207,7 +252,9 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
</div>
|
||||
<Switch
|
||||
checked={config.allowCloseable || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...config, allowCloseable: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -237,168 +284,157 @@ export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{localTabs.map((tab, index) => (
|
||||
<div
|
||||
<Collapsible
|
||||
key={tab.id}
|
||||
className="rounded-lg border bg-card p-3 shadow-sm"
|
||||
open={expandedTabs.has(tab.id)}
|
||||
onOpenChange={() => toggleTabExpand(tab.id)}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">탭 {index + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "up")}
|
||||
disabled={index === 0}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "down")}
|
||||
disabled={index === localTabs.length - 1}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRemoveTab(tab.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 탭 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">탭 라벨</Label>
|
||||
<Input
|
||||
value={tab.label}
|
||||
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
|
||||
onBlur={handleLabelBlur}
|
||||
placeholder="탭 이름"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<div className="rounded-lg border bg-card shadow-sm">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
{expandedTabs.has(tab.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs font-medium">
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
</span>
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] text-primary">
|
||||
{tab.components.length}개 컴포넌트
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "up")}
|
||||
disabled={index === 0}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "down")}
|
||||
disabled={index === localTabs.length - 1}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRemoveTab(tab.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연결된 화면</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground text-xs">로딩 중...</span>
|
||||
{/* 탭 컨텐츠 */}
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-4 border-t p-3">
|
||||
{/* 탭 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">탭 라벨</Label>
|
||||
<Input
|
||||
value={tab.label}
|
||||
onChange={(e) =>
|
||||
handleLabelChange(tab.id, e.target.value)
|
||||
}
|
||||
onBlur={handleLabelBlur}
|
||||
placeholder="탭 이름"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ScreenSelectCombobox
|
||||
screens={screens}
|
||||
selectedScreenId={tab.screenId}
|
||||
onSelect={(screenId, screenName) =>
|
||||
handleScreenSelect(tab.id, screenId, screenName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab.screenName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
선택된 화면: {tab.screenName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">비활성화</Label>
|
||||
<Switch
|
||||
checked={tab.disabled || false}
|
||||
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">비활성화</Label>
|
||||
<Switch
|
||||
checked={tab.disabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDisabledToggle(tab.id, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs">
|
||||
배치된 컴포넌트
|
||||
</Label>
|
||||
{!tab.components || tab.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tab.components.map((comp: TabInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">
|
||||
{comp.label || comp.componentType}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0},{" "}
|
||||
{comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x
|
||||
{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRemoveComponent(tab.id, comp.id)
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용 안내 */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<h4 className="mb-1 text-xs font-semibold text-blue-900">
|
||||
컴포넌트 추가 방법
|
||||
</h4>
|
||||
<ol className="list-inside list-decimal space-y-1 text-[10px] text-blue-800">
|
||||
<li>디자인 화면에서 탭을 선택합니다</li>
|
||||
<li>좌측 패널에서 원하는 컴포넌트를 드래그합니다</li>
|
||||
<li>선택한 탭 영역에 드롭하여 배치합니다</li>
|
||||
<li>컴포넌트를 드래그하여 위치를 조정합니다</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 선택 Combobox 컴포넌트
|
||||
function ScreenSelectCombobox({
|
||||
screens,
|
||||
selectedScreenId,
|
||||
onSelect,
|
||||
}: {
|
||||
screens: ScreenInfo[];
|
||||
selectedScreenId?: number;
|
||||
onSelect: (screenId: number, screenName: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedScreen = screens.find((s) => s.screenId === selectedScreenId);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
{selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||
</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>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={screen.screenName}
|
||||
onSelect={() => {
|
||||
onSelect(screen.screenId, screen.screenName);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
|
||||
selectedScreenId === screen.screenId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
코드: {screen.screenCode} | 테이블: {screen.tableName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
|
|||
"modal",
|
||||
"drawer",
|
||||
"form-layout",
|
||||
"aggregation-widget",
|
||||
]);
|
||||
|
||||
// 컴포넌트가 입력 폼인지 확인
|
||||
|
|
@ -491,7 +492,7 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
|
||||
const extractFromComponent = (comp: ComponentData, parentType?: string, parentLabel?: string) => {
|
||||
const anyComp = comp as any;
|
||||
const config = anyComp.componentConfig;
|
||||
const config = anyComp.componentConfig || anyComp.config;
|
||||
const compType = anyComp.componentType || anyComp.type;
|
||||
const compLabel = anyComp.label || anyComp.title || compType;
|
||||
|
||||
|
|
@ -728,6 +729,23 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 11. 집계 위젯 (aggregation-widget) 항목 라벨
|
||||
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
|
||||
config.items.forEach((item: any, index: number) => {
|
||||
if (item.columnLabel && typeof item.columnLabel === "string") {
|
||||
addLabel(
|
||||
`${comp.id}_agg_${item.id || index}`,
|
||||
item.columnLabel,
|
||||
"label",
|
||||
compType,
|
||||
compLabel,
|
||||
item.labelLangKeyId,
|
||||
item.labelLangKey
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 탐색
|
||||
if (anyComp.children && Array.isArray(anyComp.children)) {
|
||||
anyComp.children.forEach((child: ComponentData) => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Database, GripVertical } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
import TablesPanel from "./TablesPanel";
|
||||
|
||||
|
|
@ -19,6 +18,9 @@ interface ComponentsPanelProps {
|
|||
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
|
||||
selectedTableName?: string;
|
||||
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
|
||||
// 테이블 선택 관련 props
|
||||
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
|
||||
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부 (기본: 테이블 없으면 표시)
|
||||
}
|
||||
|
||||
export function ComponentsPanel({
|
||||
|
|
@ -29,45 +31,124 @@ export function ComponentsPanel({
|
|||
onTableDragStart,
|
||||
selectedTableName,
|
||||
placedColumns,
|
||||
onTableSelect,
|
||||
showTableSelector = true,
|
||||
}: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
|
||||
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||
const hasTableList = components.some((c) => c.id === "table-list");
|
||||
if (!hasTableList) {
|
||||
components.push({
|
||||
id: "table-list",
|
||||
name: "데이터 테이블 v2",
|
||||
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "display",
|
||||
tags: ["table", "data", "crud"],
|
||||
defaultSize: { width: 1000, height: 680 },
|
||||
} as ComponentDefinition);
|
||||
}
|
||||
|
||||
// v2-table-list가 자동 등록되므로 수동 추가 불필요
|
||||
return components;
|
||||
}, []);
|
||||
|
||||
// Unified 컴포넌트 정의 (새로운 통합 컴포넌트 시스템)
|
||||
// 입력 컴포넌트(unified-input, unified-select, unified-date)는 테이블 컬럼 드래그 시 자동 생성되므로 숨김
|
||||
const unifiedComponents = useMemo(
|
||||
() =>
|
||||
[
|
||||
// unified-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// unified-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// unified-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// unified-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
|
||||
// unified-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
|
||||
// unified-list: table-list, card-display로 분리하여 숨김 처리
|
||||
// unified-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
|
||||
// unified-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
|
||||
// unified-hierarchy 제거 - 현재 미사용
|
||||
{
|
||||
id: "v2-unified-repeater",
|
||||
name: "리피터 그리드",
|
||||
description: "행 단위로 데이터를 추가/수정/삭제",
|
||||
category: "data" as ComponentCategory,
|
||||
tags: ["repeater", "table", "modal", "button", "unified", "v2"],
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
},
|
||||
] as ComponentDefinition[],
|
||||
[],
|
||||
);
|
||||
|
||||
// 카테고리별 컴포넌트 그룹화
|
||||
const componentsByCategory = useMemo(() => {
|
||||
// 숨길 컴포넌트 ID 목록 (기본 입력 컴포넌트들)
|
||||
const hiddenInputComponents = ["text-input", "number-input", "date-input", "textarea-basic"];
|
||||
// 숨길 컴포넌트 ID 목록
|
||||
const hiddenComponents = [
|
||||
// 기본 입력 컴포넌트 (테이블 컬럼 드래그 시 자동 생성)
|
||||
"text-input",
|
||||
"number-input",
|
||||
"date-input",
|
||||
"textarea-basic",
|
||||
// Unified 컴포넌트로 대체됨
|
||||
"image-widget", // → UnifiedMedia (image)
|
||||
"file-upload", // → UnifiedMedia (file)
|
||||
"entity-search-input", // → UnifiedSelect (entity 모드)
|
||||
"autocomplete-search-input", // → UnifiedSelect (autocomplete 모드)
|
||||
// DataFlow 전용 (일반 화면에서 불필요)
|
||||
"mail-recipient-selector",
|
||||
// 현재 사용 안함
|
||||
"repeater-field-group",
|
||||
// unified-repeater로 통합됨
|
||||
"simple-repeater-table", // → unified-repeater (inline 모드)
|
||||
"modal-repeater-table", // → unified-repeater (modal 모드)
|
||||
// 특수 업무용 컴포넌트 (일반 화면에서 불필요)
|
||||
"tax-invoice-list", // 세금계산서 전용
|
||||
"customer-item-mapping", // 고객-품목 매핑 전용
|
||||
// card-display는 별도 컴포넌트로 유지
|
||||
// unified-media로 통합됨
|
||||
"image-display", // → unified-media (image)
|
||||
// 공통코드관리로 통합 예정
|
||||
"category-manager", // → 공통코드관리 기능으로 통합 예정
|
||||
// 분할 패널 정리 (split-panel-layout v1 유지)
|
||||
"split-panel-layout2", // → split-panel-layout로 통합
|
||||
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
|
||||
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
|
||||
"accordion-basic", // 아코디언 컴포넌트
|
||||
"conditional-container", // 조건부 컨테이너
|
||||
"universal-form-modal", // 범용 폼 모달
|
||||
// 통합 미디어 (테이블 컬럼 입력 타입으로 사용)
|
||||
"unified-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
|
||||
// 플로우 위젯 숨김 처리
|
||||
"flow-widget",
|
||||
// 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능
|
||||
"selected-items-detail-input",
|
||||
// 연관 데이터 버튼 - unified-repeater로 대체 가능
|
||||
"related-data-buttons",
|
||||
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
|
||||
"button-primary", // → v2-button-primary
|
||||
"split-panel-layout", // → v2-split-panel-layout
|
||||
"aggregation-widget", // → v2-aggregation-widget
|
||||
"card-display", // → v2-card-display
|
||||
"table-list", // → v2-table-list
|
||||
"text-display", // → v2-text-display
|
||||
"divider-line", // → v2-divider-line
|
||||
"numbering-rule", // → v2-numbering-rule
|
||||
"section-paper", // → v2-section-paper
|
||||
"section-card", // → v2-section-card
|
||||
"location-swap-selector", // → v2-location-swap-selector
|
||||
"rack-structure", // → v2-rack-structure
|
||||
"unified-repeater", // → v2-unified-repeater (아래 unifiedComponents에서 별도 처리)
|
||||
"repeat-container", // → v2-repeat-container
|
||||
"repeat-screen-modal", // → v2-repeat-screen-modal
|
||||
"pivot-grid", // → v2-pivot-grid
|
||||
"table-search-widget", // → v2-table-search-widget
|
||||
"tabs", // → v2-tabs
|
||||
"tabs-widget", // → v2-tabs-widget
|
||||
];
|
||||
|
||||
return {
|
||||
input: allComponents.filter(
|
||||
(c) => c.category === ComponentCategory.INPUT && !hiddenInputComponents.includes(c.id),
|
||||
input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && !hiddenComponents.includes(c.id)),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION && !hiddenComponents.includes(c.id)),
|
||||
display: allComponents.filter(
|
||||
(c) => c.category === ComponentCategory.DISPLAY && !hiddenComponents.includes(c.id),
|
||||
),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
|
||||
data: allComponents.filter((c) => c.category === ComponentCategory.DATA && !hiddenComponents.includes(c.id)),
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT && !hiddenComponents.includes(c.id)),
|
||||
utility: allComponents.filter(
|
||||
(c) => c.category === ComponentCategory.UTILITY && !hiddenComponents.includes(c.id),
|
||||
),
|
||||
unified: unifiedComponents,
|
||||
};
|
||||
}, [allComponents]);
|
||||
}, [allComponents, unifiedComponents]);
|
||||
|
||||
// 카테고리별 검색 필터링
|
||||
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
|
||||
|
|
@ -114,7 +195,25 @@ export function ComponentsPanel({
|
|||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
// 컴포넌트 카드 렌더링 함수
|
||||
// 카테고리별 배경색 매핑
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "data":
|
||||
return "from-blue-500/10 to-blue-600/10 text-blue-600 group-hover:from-blue-500/20 group-hover:to-blue-600/20";
|
||||
case "display":
|
||||
return "from-emerald-500/10 to-emerald-600/10 text-emerald-600 group-hover:from-emerald-500/20 group-hover:to-emerald-600/20";
|
||||
case "input":
|
||||
return "from-violet-500/10 to-violet-600/10 text-violet-600 group-hover:from-violet-500/20 group-hover:to-violet-600/20";
|
||||
case "layout":
|
||||
return "from-amber-500/10 to-amber-600/10 text-amber-600 group-hover:from-amber-500/20 group-hover:to-amber-600/20";
|
||||
case "action":
|
||||
return "from-rose-500/10 to-rose-600/10 text-rose-600 group-hover:from-rose-500/20 group-hover:to-rose-600/20";
|
||||
default:
|
||||
return "from-slate-500/10 to-slate-600/10 text-slate-600 group-hover:from-slate-500/20 group-hover:to-slate-600/20";
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 카드 렌더링 함수 (컴팩트 버전)
|
||||
const renderComponentCard = (component: ComponentDefinition) => (
|
||||
<div
|
||||
key={component.id}
|
||||
|
|
@ -128,21 +227,27 @@ export function ComponentsPanel({
|
|||
e.currentTarget.style.opacity = "1";
|
||||
e.currentTarget.style.transform = "none";
|
||||
}}
|
||||
className="group bg-card hover:border-primary/50 cursor-grab rounded-lg border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md active:translate-y-0 active:scale-[0.98] active:cursor-grabbing"
|
||||
className="group bg-card hover:border-primary/40 cursor-grab rounded-lg border px-3 py-2.5 transition-all duration-200 hover:shadow-sm active:scale-[0.98] active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary group-hover:bg-primary/20 flex h-10 w-10 items-center justify-center rounded-md transition-all duration-200">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gradient-to-br transition-all duration-200 ${getCategoryColor(component.category)}`}
|
||||
>
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="mb-1 text-xs leading-tight font-semibold">{component.name}</h4>
|
||||
<p className="text-muted-foreground mb-1.5 line-clamp-2 text-xs leading-relaxed">{component.description}</p>
|
||||
<div className="flex items-center">
|
||||
<span className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
<span className="text-foreground block truncate text-xs font-medium">{component.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-[10px] capitalize">{component.category}</span>
|
||||
<span className="text-muted-foreground/60 text-[10px]">|</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground/40 group-hover:text-muted-foreground/60 transition-colors">
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -186,124 +291,50 @@ export function ComponentsPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
|
||||
<TabsTrigger
|
||||
value="tables"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="테이블"
|
||||
>
|
||||
{/* 테이블 / 컴포넌트 탭 */}
|
||||
<Tabs defaultValue="tables" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full shrink-0 grid-cols-2 gap-1 p-1">
|
||||
<TabsTrigger value="tables" className="flex items-center justify-center gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="hidden">테이블</span>
|
||||
<span>테이블</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="input" className="flex items-center justify-center gap-0.5 px-0 text-[10px]" title="입력">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden">입력</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="data"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="데이터"
|
||||
>
|
||||
<Grid className="h-3 w-3" />
|
||||
<span className="hidden">데이터</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="action"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="액션"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="hidden">액션</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="display"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="표시"
|
||||
>
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
<span className="hidden">표시</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="layout"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="레이아웃"
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
<span className="hidden">레이아웃</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="utility"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="유틸리티"
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden">유틸리티</span>
|
||||
<TabsTrigger value="components" className="flex items-center justify-center gap-1 text-xs">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>컴포넌트</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 테이블 탭 */}
|
||||
{/* 테이블 컬럼 탭 */}
|
||||
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
|
||||
{tables.length > 0 && onTableDragStart ? (
|
||||
<TablesPanel
|
||||
tables={tables}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={onSearchChange || (() => {})}
|
||||
onDragStart={onTableDragStart}
|
||||
selectedTableName={selectedTableName}
|
||||
placedColumns={placedColumns}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-center">
|
||||
<div className="p-6">
|
||||
<Database className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-xs font-medium">테이블이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TablesPanel
|
||||
tables={tables}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={onSearchChange || (() => {})}
|
||||
onDragStart={onTableDragStart || (() => {})}
|
||||
selectedTableName={selectedTableName}
|
||||
placedColumns={placedColumns}
|
||||
onTableSelect={onTableSelect}
|
||||
showTableSelector={showTableSelector}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 입력 컴포넌트 */}
|
||||
<TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("input").length > 0
|
||||
? getFilteredComponents("input").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
{/* 컴포넌트 탭 */}
|
||||
<TabsContent value="components" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{(() => {
|
||||
const allFilteredComponents = [
|
||||
...getFilteredComponents("unified"),
|
||||
...getFilteredComponents("action"),
|
||||
...getFilteredComponents("display"),
|
||||
...getFilteredComponents("data"),
|
||||
...getFilteredComponents("layout"),
|
||||
...getFilteredComponents("input"),
|
||||
...getFilteredComponents("utility"),
|
||||
];
|
||||
|
||||
{/* 데이터 컴포넌트 */}
|
||||
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("data").length > 0
|
||||
? getFilteredComponents("data").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 액션 컴포넌트 */}
|
||||
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("action").length > 0
|
||||
? getFilteredComponents("action").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 표시 컴포넌트 */}
|
||||
<TabsContent value="display" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("display").length > 0
|
||||
? getFilteredComponents("display").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<TabsContent value="layout" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("layout").length > 0
|
||||
? getFilteredComponents("layout").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 유틸리티 컴포넌트 */}
|
||||
<TabsContent value="utility" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("utility").length > 0
|
||||
? getFilteredComponents("utility").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
return allFilteredComponents.length > 0
|
||||
? allFilteredComponents.map(renderComponentCard)
|
||||
: renderEmptyState();
|
||||
})()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -461,5 +461,3 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Settings, Database } from "lucide-react";
|
||||
import { Settings, Database, Zap } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -22,6 +22,8 @@ import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
|||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
|
||||
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
|
||||
import { ConditionalConfig } from "@/types/unified-components";
|
||||
|
||||
// 새로운 컴포넌트 설정 패널들 import
|
||||
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
||||
|
|
@ -1192,7 +1194,28 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* 🆕 테이블 데이터 자동 입력 섹션 (component 타입용) */}
|
||||
{/* 조건부 표시 설정 (component 타입용) */}
|
||||
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||
<ConditionalConfigPanel
|
||||
config={selectedComponent.conditional as ConditionalConfig | undefined}
|
||||
onChange={(newConfig) => {
|
||||
onUpdateProperty(selectedComponent.id, "conditional", newConfig);
|
||||
}}
|
||||
availableFields={components
|
||||
.filter((c) => c.id !== selectedComponent.id && (c.type === "widget" || c.type === "component"))
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
label: (c as any).label || (c as any).columnName || c.id,
|
||||
type: (c as any).widgetType || (c as any).componentConfig?.webType,
|
||||
options: (c as any).webTypeConfig?.options || [],
|
||||
}))}
|
||||
currentComponentId={selectedComponent.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 테이블 데이터 자동 입력 섹션 (component 타입용) */}
|
||||
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Database className="h-4 w-4" />
|
||||
|
|
@ -1400,9 +1423,29 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 상세 설정 영역 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 w-full min-w-0">
|
||||
<div className="space-y-6 w-full min-w-0">
|
||||
{console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)}
|
||||
{/* 🆕 자동 입력 섹션 */}
|
||||
<div className="space-y-4 rounded-md border border-red-500 bg-yellow-50 p-4">
|
||||
{/* 조건부 표시 설정 */}
|
||||
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||
<ConditionalConfigPanel
|
||||
config={widget.conditional as ConditionalConfig | undefined}
|
||||
onChange={(newConfig) => {
|
||||
onUpdateProperty(widget.id, "conditional", newConfig);
|
||||
}}
|
||||
availableFields={components
|
||||
.filter((c) => c.id !== widget.id && (c.type === "widget" || c.type === "component"))
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
label: (c as any).label || (c as any).columnName || c.id,
|
||||
type: (c as any).widgetType || (c as any).componentConfig?.webType,
|
||||
options: (c as any).webTypeConfig?.options || [],
|
||||
}))}
|
||||
currentComponentId={widget.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 자동 입력 섹션 */}
|
||||
<div className="space-y-4 rounded-md border border-gray-200 p-4">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
🔥 테이블 데이터 자동 입력 (테스트)
|
||||
|
|
|
|||
|
|
@ -413,5 +413,3 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Database, Type, Hash, Calendar, CheckSquare, List, AlignLeft, Code, Building, File } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Database,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
List,
|
||||
AlignLeft,
|
||||
Code,
|
||||
Building,
|
||||
File,
|
||||
Link2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { TableInfo, WebType } from "@/types/screen";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface EntityJoinColumn {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface EntityJoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
availableColumns: EntityJoinColumn[];
|
||||
}
|
||||
|
||||
interface TablesPanelProps {
|
||||
tables: TableInfo[];
|
||||
|
|
@ -12,6 +53,9 @@ interface TablesPanelProps {
|
|||
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
|
||||
selectedTableName?: string;
|
||||
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식)
|
||||
// 테이블 선택 관련 props
|
||||
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
|
||||
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부
|
||||
}
|
||||
|
||||
// 위젯 타입별 아이콘
|
||||
|
|
@ -52,16 +96,135 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
searchTerm,
|
||||
onDragStart,
|
||||
placedColumns = new Set(),
|
||||
onTableSelect,
|
||||
showTableSelector = false,
|
||||
}) => {
|
||||
// 엔티티 조인 컬럼 상태
|
||||
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
|
||||
|
||||
// 전체 테이블 목록 (테이블 선택용)
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingAllTables, setLoadingAllTables] = useState(false);
|
||||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||
const [showTableSelectDropdown, setShowTableSelectDropdown] = useState(false);
|
||||
|
||||
// 시스템 컬럼 목록 (숨김 처리)
|
||||
const systemColumns = new Set([
|
||||
'id',
|
||||
'created_date',
|
||||
'updated_date',
|
||||
'writer',
|
||||
'company_code'
|
||||
"id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"writer",
|
||||
"company_code",
|
||||
]);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
const loadAllTables = useCallback(async () => {
|
||||
if (allTables.length > 0) return; // 이미 로드됨
|
||||
|
||||
setLoadingAllTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
} finally {
|
||||
setLoadingAllTables(false);
|
||||
}
|
||||
}, [allTables.length]);
|
||||
|
||||
// 테이블 선택 시 호출
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
setShowTableSelectDropdown(false);
|
||||
setTableSearchTerm("");
|
||||
onTableSelect?.(tableName);
|
||||
};
|
||||
|
||||
// 필터링된 테이블 목록
|
||||
const filteredAllTables = tableSearchTerm
|
||||
? allTables.filter(
|
||||
(t) =>
|
||||
t.tableName.toLowerCase().includes(tableSearchTerm.toLowerCase()) ||
|
||||
t.displayName.toLowerCase().includes(tableSearchTerm.toLowerCase())
|
||||
)
|
||||
: allTables;
|
||||
|
||||
// 메인 테이블명 추출
|
||||
const mainTableName = tables[0]?.tableName;
|
||||
|
||||
// 엔티티 조인 컬럼 로드
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!mainTableName) {
|
||||
setEntityJoinTables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(mainTableName);
|
||||
setEntityJoinTables(result.joinTables || []);
|
||||
// 기본적으로 모든 조인 테이블 펼치기
|
||||
setExpandedJoinTables(new Set(result.joinTables?.map((t) => t.tableName) || []));
|
||||
} catch (error) {
|
||||
console.error("엔티티 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinTables([]);
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEntityJoinColumns();
|
||||
}, [mainTableName]);
|
||||
|
||||
// 조인 테이블 펼치기/접기 토글
|
||||
const toggleJoinTable = (tableName: string) => {
|
||||
setExpandedJoinTables((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(tableName)) {
|
||||
newSet.delete(tableName);
|
||||
} else {
|
||||
newSet.add(tableName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 엔티티 조인 컬럼 드래그 핸들러
|
||||
const handleEntityJoinDragStart = (
|
||||
e: React.DragEvent,
|
||||
joinTable: EntityJoinTable,
|
||||
column: EntityJoinColumn,
|
||||
) => {
|
||||
// "테이블명.컬럼명" 형식으로 컬럼 정보 생성
|
||||
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
|
||||
|
||||
const columnData = {
|
||||
columnName: fullColumnName,
|
||||
columnLabel: column.columnLabel || column.columnName,
|
||||
dataType: column.dataType,
|
||||
widgetType: "text" as WebType,
|
||||
isEntityJoin: true,
|
||||
entityJoinTable: joinTable.tableName,
|
||||
entityJoinColumn: column.columnName,
|
||||
};
|
||||
|
||||
// 기존 테이블 정보를 기반으로 가상의 테이블 정보 생성
|
||||
const virtualTable: TableInfo = {
|
||||
tableName: mainTableName || "",
|
||||
tableLabel: tables[0]?.tableLabel || mainTableName || "",
|
||||
columns: [columnData],
|
||||
};
|
||||
|
||||
onDragStart(e, virtualTable, columnData);
|
||||
};
|
||||
|
||||
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
|
||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||
...table,
|
||||
|
|
@ -105,6 +268,91 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 테이블 선택 버튼 (메인 테이블이 없을 때 또는 showTableSelector가 true일 때) */}
|
||||
{(showTableSelector || tables.length === 0) && (
|
||||
<div className="border-b p-3">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => {
|
||||
setShowTableSelectDropdown(!showTableSelectDropdown);
|
||||
if (!showTableSelectDropdown) {
|
||||
loadAllTables();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{tables.length > 0 ? "테이블 추가/변경" : "테이블 선택"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 드롭다운 */}
|
||||
{showTableSelectDropdown && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-lg">
|
||||
{/* 검색 */}
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="테이블 검색..."
|
||||
value={tableSearchTerm}
|
||||
onChange={(e) => setTableSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full rounded-md border px-8 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{tableSearchTerm && (
|
||||
<button
|
||||
onClick={() => setTableSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 목록 */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{loadingAllTables ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">로드 중...</div>
|
||||
) : filteredAllTables.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-gray-500">
|
||||
{tableSearchTerm ? "검색 결과 없음" : "테이블 없음"}
|
||||
</div>
|
||||
) : (
|
||||
filteredAllTables.map((t) => (
|
||||
<button
|
||||
key={t.tableName}
|
||||
onClick={() => handleTableSelect(t.tableName)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100"
|
||||
>
|
||||
<Database className="h-3.5 w-3.5 text-blue-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{t.displayName}</div>
|
||||
<div className="truncate text-xs text-gray-500">{t.tableName}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 현재 테이블 정보 */}
|
||||
{tables.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
현재: {tables[0]?.tableLabel || tables[0]?.tableName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블과 컬럼 평면 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -126,18 +374,19 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
{table.columns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-accent/50 flex cursor-grab items-center justify-between rounded-md p-2 transition-colors"
|
||||
className="hover:bg-accent/50 flex cursor-grab items-center gap-2 rounded-md p-2 transition-colors"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table, column)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{getWidgetIcon(column.widgetType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{column.columnLabel || column.columnName}</div>
|
||||
<div className="text-muted-foreground truncate text-[10px]">{column.dataType}</div>
|
||||
<div
|
||||
className="text-xs font-medium"
|
||||
title={column.columnLabel || column.columnName}
|
||||
>
|
||||
{column.columnLabel || column.columnName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||
{column.widgetType}
|
||||
|
|
@ -153,6 +402,103 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 엔티티 조인 컬럼 섹션 */}
|
||||
{entityJoinTables.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Link2 className="h-3.5 w-3.5 text-cyan-600" />
|
||||
<span className="text-muted-foreground text-xs font-medium">엔티티 조인 컬럼</span>
|
||||
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
|
||||
{entityJoinTables.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{entityJoinTables.map((joinTable) => {
|
||||
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||
// 검색어로 필터링
|
||||
const filteredColumns = searchTerm
|
||||
? joinTable.availableColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
col.columnLabel.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
: joinTable.availableColumns;
|
||||
|
||||
// 검색 결과가 없으면 표시하지 않음
|
||||
if (searchTerm && filteredColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={joinTable.tableName} className="space-y-1">
|
||||
{/* 조인 테이블 헤더 */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
||||
onClick={() => toggleJoinTable(joinTable.tableName)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-cyan-600" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-cyan-600" />
|
||||
)}
|
||||
<Building className="h-3.5 w-3.5 text-cyan-600" />
|
||||
<span className="text-xs font-semibold text-cyan-800">{joinTable.tableName}</span>
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||
{filteredColumns.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 pl-4">
|
||||
{filteredColumns.map((column) => {
|
||||
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
|
||||
const isPlaced = placedColumns.has(fullColumnName);
|
||||
|
||||
if (isPlaced) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="flex cursor-grab items-center gap-2 rounded-md border border-cyan-200 bg-cyan-50/50 p-2 transition-colors hover:bg-cyan-100"
|
||||
draggable
|
||||
onDragStart={(e) => handleEntityJoinDragStart(e, joinTable, column)}
|
||||
title="읽기 전용 - 조인된 테이블에서 참조"
|
||||
>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-cyan-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium" title={column.columnLabel || column.columnName}>
|
||||
{column.columnLabel || column.columnName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="h-4 border-gray-300 bg-gray-100 px-1 text-[9px] text-gray-600">
|
||||
읽기
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-4 border-cyan-300 px-1.5 text-[10px]">
|
||||
{column.inputType || "text"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 표시 */}
|
||||
{loadingEntityJoins && (
|
||||
<div className="text-muted-foreground flex items-center justify-center py-4 text-xs">
|
||||
엔티티 조인 컬럼 로드 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -393,33 +393,33 @@ const fallbackTemplates: TemplateComponent[] = [
|
|||
],
|
||||
},
|
||||
|
||||
// 아코디언 영역
|
||||
{
|
||||
id: "area-accordion",
|
||||
name: "아코디언 영역",
|
||||
description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
|
||||
category: "area",
|
||||
icon: <ChevronDown className="h-4 w-4" />,
|
||||
defaultSize: { width: 500, height: 600 },
|
||||
components: [
|
||||
{
|
||||
type: "area",
|
||||
label: "아코디언 영역",
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 500, height: 600 },
|
||||
layoutType: "accordion",
|
||||
layoutConfig: {
|
||||
allowMultiple: false,
|
||||
defaultExpanded: ["section1"],
|
||||
},
|
||||
style: {
|
||||
backgroundColor: "#ffffff",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// 아코디언 영역 - 숨김 처리
|
||||
// {
|
||||
// id: "area-accordion",
|
||||
// name: "아코디언 영역",
|
||||
// description: "접고 펼칠 수 있는 섹션들로 구성된 영역",
|
||||
// category: "area",
|
||||
// icon: <ChevronDown className="h-4 w-4" />,
|
||||
// defaultSize: { width: 500, height: 600 },
|
||||
// components: [
|
||||
// {
|
||||
// type: "area",
|
||||
// label: "아코디언 영역",
|
||||
// position: { x: 0, y: 0 },
|
||||
// size: { width: 500, height: 600 },
|
||||
// layoutType: "accordion",
|
||||
// layoutConfig: {
|
||||
// allowMultiple: false,
|
||||
// defaultExpanded: ["section1"],
|
||||
// },
|
||||
// style: {
|
||||
// backgroundColor: "#ffffff",
|
||||
// border: "1px solid #e5e7eb",
|
||||
// borderRadius: "8px",
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
interface TemplatesPanelProps {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette } from "lucide-react";
|
||||
import {
|
||||
ComponentData,
|
||||
WebType,
|
||||
|
|
@ -59,24 +59,15 @@ import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
|||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
import StyleEditor from "../StyleEditor";
|
||||
import ResolutionPanel from "./ResolutionPanel";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Grid3X3, Eye, EyeOff, Zap } from "lucide-react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ConditionalConfigPanel } from "@/components/unified/ConditionalConfigPanel";
|
||||
import { ConditionalConfig } from "@/types/unified-components";
|
||||
|
||||
interface UnifiedPropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
tables: TableInfo[];
|
||||
gridSettings?: {
|
||||
columns: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
snapToGrid: boolean;
|
||||
showGrid: boolean;
|
||||
gridColor?: string;
|
||||
gridOpacity?: number;
|
||||
};
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
onGridSettingsChange?: (settings: any) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onCopyComponent?: (componentId: string) => void;
|
||||
currentTable?: TableInfo;
|
||||
|
|
@ -84,9 +75,6 @@ interface UnifiedPropertiesPanelProps {
|
|||
dragState?: any;
|
||||
// 스타일 관련
|
||||
onStyleChange?: (style: any) => void;
|
||||
// 해상도 관련
|
||||
currentResolution?: { name: string; width: number; height: number };
|
||||
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
|
||||
// 🆕 플로우 위젯 감지용
|
||||
allComponents?: ComponentData[];
|
||||
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
|
||||
|
|
@ -98,9 +86,7 @@ interface UnifiedPropertiesPanelProps {
|
|||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
selectedComponent,
|
||||
tables,
|
||||
gridSettings,
|
||||
onUpdateProperty,
|
||||
onGridSettingsChange,
|
||||
onDeleteComponent,
|
||||
onCopyComponent,
|
||||
currentTable,
|
||||
|
|
@ -109,8 +95,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
dragState,
|
||||
onStyleChange,
|
||||
menuObjid,
|
||||
currentResolution,
|
||||
onResolutionChange,
|
||||
allComponents = [], // 🆕 기본값 빈 배열
|
||||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
|
|
@ -163,106 +147,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}, [selectedComponent?.size?.width, selectedComponent?.id]);
|
||||
|
||||
// 격자 설정 업데이트 함수 (early return 이전에 정의)
|
||||
const updateGridSetting = (key: string, value: any) => {
|
||||
if (onGridSettingsChange && gridSettings) {
|
||||
onGridSettingsChange({
|
||||
...gridSettings,
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 격자 설정 렌더링 (early return 이전에 정의)
|
||||
const renderGridSettings = () => {
|
||||
if (!gridSettings || !onGridSettingsChange) return null;
|
||||
|
||||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor(
|
||||
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
|
||||
(MIN_COLUMN_WIDTH + gridSettings.gap),
|
||||
)
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Grid3X3 className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="text-primary h-3 w-3" />
|
||||
) : (
|
||||
<EyeOff className="text-muted-foreground h-3 w-3" />
|
||||
)}
|
||||
<Label htmlFor="showGrid" className="text-xs font-medium">
|
||||
격자 표시
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("showGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="text-primary h-3 w-3" />
|
||||
<Label htmlFor="snapToGrid" className="text-xs font-medium">
|
||||
격자 스냅
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="snapToGrid"
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="bg-muted/50 rounded-md p-2">
|
||||
<p className="text-muted-foreground text-[10px]">모든 컴포넌트는 10px 단위로 자동 배치됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||
// 컴포넌트가 선택되지 않았을 때는 안내 메시지만 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-x-auto bg-white">
|
||||
{/* 해상도 설정과 격자 설정 표시 */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Monitor className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||
</div>
|
||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 격자 설정 */}
|
||||
{renderGridSettings()}
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<Separator className="my-4" />
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Settings className="text-muted-foreground/30 mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-[10px]">컴포넌트를 선택하여</p>
|
||||
|
|
@ -313,6 +204,53 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
// 🆕 Unified 컴포넌트 직접 감지 및 설정 패널 렌더링
|
||||
if (componentId?.startsWith("unified-")) {
|
||||
const unifiedConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
|
||||
"unified-input": require("@/components/unified/config-panels/UnifiedInputConfigPanel").UnifiedInputConfigPanel,
|
||||
"unified-select": require("@/components/unified/config-panels/UnifiedSelectConfigPanel")
|
||||
.UnifiedSelectConfigPanel,
|
||||
"unified-date": require("@/components/unified/config-panels/UnifiedDateConfigPanel").UnifiedDateConfigPanel,
|
||||
"unified-list": require("@/components/unified/config-panels/UnifiedListConfigPanel").UnifiedListConfigPanel,
|
||||
"unified-layout": require("@/components/unified/config-panels/UnifiedLayoutConfigPanel")
|
||||
.UnifiedLayoutConfigPanel,
|
||||
"unified-group": require("@/components/unified/config-panels/UnifiedGroupConfigPanel").UnifiedGroupConfigPanel,
|
||||
"unified-media": require("@/components/unified/config-panels/UnifiedMediaConfigPanel").UnifiedMediaConfigPanel,
|
||||
"unified-biz": require("@/components/unified/config-panels/UnifiedBizConfigPanel").UnifiedBizConfigPanel,
|
||||
"unified-hierarchy": require("@/components/unified/config-panels/UnifiedHierarchyConfigPanel")
|
||||
.UnifiedHierarchyConfigPanel,
|
||||
};
|
||||
|
||||
const UnifiedConfigPanel = unifiedConfigPanels[componentId];
|
||||
if (UnifiedConfigPanel) {
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
const handleUnifiedConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
|
||||
};
|
||||
|
||||
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
|
||||
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
|
||||
|
||||
// 현재 화면의 테이블명 가져오기
|
||||
const currentTableName = tables?.[0]?.tableName;
|
||||
|
||||
// 컴포넌트별 추가 props
|
||||
const extraProps: Record<string, any> = {};
|
||||
if (componentId === "unified-select") {
|
||||
extraProps.inputType = inputType;
|
||||
}
|
||||
if (componentId === "unified-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<UnifiedConfigPanel config={currentConfig} onChange={handleUnifiedConfigChange} {...extraProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
||||
|
|
@ -347,10 +285,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
|
@ -368,18 +302,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
|
||||
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
||||
componentId,
|
||||
definitionName: definition?.name,
|
||||
hasDefinition: !!definition,
|
||||
});
|
||||
// ConfigPanel이 없으면 아래 switch case로 넘어감
|
||||
}
|
||||
// ConfigPanel이 없으면 아래 switch case로 넘어감
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||||
|
|
@ -387,6 +316,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
case "button":
|
||||
case "button-primary":
|
||||
case "button-secondary":
|
||||
case "v2-button-primary":
|
||||
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||||
return (
|
||||
<ButtonConfigPanel
|
||||
|
|
@ -715,16 +645,89 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const group = selectedComponent as GroupComponent;
|
||||
const area = selectedComponent as AreaComponent;
|
||||
|
||||
// 라벨 설정이 표시될 입력 필드 타입들
|
||||
const inputFieldTypes = [
|
||||
"text",
|
||||
"number",
|
||||
"decimal",
|
||||
"date",
|
||||
"datetime",
|
||||
"time",
|
||||
"email",
|
||||
"tel",
|
||||
"url",
|
||||
"password",
|
||||
"textarea",
|
||||
"select",
|
||||
"dropdown",
|
||||
"entity",
|
||||
"code",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"boolean",
|
||||
"file",
|
||||
"autocomplete",
|
||||
"text-input",
|
||||
"number-input",
|
||||
"date-input",
|
||||
"textarea-basic",
|
||||
"select-basic",
|
||||
"checkbox-basic",
|
||||
"radio-basic",
|
||||
"entity-search-input",
|
||||
"autocomplete-search-input",
|
||||
// 새로운 통합 입력 컴포넌트
|
||||
"unified-input",
|
||||
"unified-select",
|
||||
"unified-entity-select",
|
||||
"unified-checkbox",
|
||||
"unified-radio",
|
||||
"unified-textarea",
|
||||
"unified-date",
|
||||
"unified-datetime",
|
||||
"unified-time",
|
||||
"unified-file",
|
||||
];
|
||||
|
||||
// 현재 컴포넌트가 입력 필드인지 확인
|
||||
const componentType = widget.widgetType || (widget as any).componentId || (widget as any).componentType;
|
||||
const isInputField = inputFieldTypes.includes(componentType);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 라벨 + 최소 높이 (같은 행) */}
|
||||
{/* 너비 + 높이 (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
value={widget.label || ""}
|
||||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
type="number"
|
||||
min={10}
|
||||
max={3840}
|
||||
step="1"
|
||||
value={localWidth}
|
||||
onChange={(e) => {
|
||||
setLocalWidth(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
setLocalWidth(String(snappedValue));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
setLocalWidth(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="100"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -734,11 +737,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
type="number"
|
||||
value={localHeight}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||
setLocalHeight(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스를 잃을 때 10px 단위로 스냅
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
if (value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
|
|
@ -747,7 +748,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value) || 0;
|
||||
if (value >= 10) {
|
||||
|
|
@ -755,7 +755,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdate("size.height", snappedValue);
|
||||
setLocalHeight(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur(); // 포커스 제거
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
|
|
@ -765,19 +765,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder (widget만) */}
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={widget.placeholder || ""}
|
||||
onChange={(e) => handleUpdate("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (group/area) */}
|
||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -804,116 +791,74 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Width + Z-Index (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3840}
|
||||
step="1"
|
||||
value={localWidth}
|
||||
onChange={(e) => {
|
||||
// 입력 중에는 로컬 상태만 업데이트 (자유 입력)
|
||||
setLocalWidth(e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// 포커스를 잃을 때 10px 단위로 스냅
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
setLocalWidth(String(snappedValue));
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 키를 누르면 즉시 적용 (10px 단위로 스냅)
|
||||
if (e.key === "Enter") {
|
||||
const value = parseInt(e.currentTarget.value, 10);
|
||||
if (!isNaN(value) && value >= 10) {
|
||||
const snappedValue = Math.round(value / 10) * 10;
|
||||
handleUpdate("size.width", snappedValue);
|
||||
setLocalWidth(String(snappedValue));
|
||||
}
|
||||
e.currentTarget.blur(); // 포커스 제거
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* Z-Index */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 라벨 스타일 */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
|
||||
라벨 스타일
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 라벨 스타일 - 입력 필드에서만 표시 */}
|
||||
{isInputField && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
|
||||
라벨 스타일
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<ColorPickerWithTransparent
|
||||
value={selectedComponent.style?.labelColor}
|
||||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||
defaultColor="#212121"
|
||||
placeholder="#212121"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<ColorPickerWithTransparent
|
||||
value={selectedComponent.style?.labelColor}
|
||||
onChange={(value) => handleUpdate("style.labelColor", value)}
|
||||
defaultColor="#212121"
|
||||
placeholder="#212121"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">표시</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">표시</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -989,6 +934,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 3.5. Unified 컴포넌트 - 반드시 다른 체크보다 먼저 처리
|
||||
const unifiedComponentType =
|
||||
(selectedComponent as any).componentType || selectedComponent.componentConfig?.type || "";
|
||||
if (unifiedComponentType.startsWith("unified-")) {
|
||||
const configPanel = renderComponentConfigPanel();
|
||||
if (configPanel) {
|
||||
return <div className="space-y-4">{configPanel}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 새로운 컴포넌트 시스템 (button, card 등)
|
||||
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
||||
const hasNewConfigPanel =
|
||||
|
|
@ -997,6 +952,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
"button",
|
||||
"button-primary",
|
||||
"button-secondary",
|
||||
"v2-button-primary",
|
||||
"card",
|
||||
"dashboard",
|
||||
"stats",
|
||||
|
|
@ -1443,24 +1399,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
{/* 통합 컨텐츠 (탭 제거) */}
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Monitor className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||
</div>
|
||||
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 격자 설정 - 해상도 설정 아래 표시 */}
|
||||
{renderGridSettings()}
|
||||
{gridSettings && onGridSettingsChange && <Separator className="my-2" />}
|
||||
|
||||
{/* 기본 설정 */}
|
||||
{renderBasicTab()}
|
||||
|
||||
|
|
@ -1468,6 +1406,93 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Separator className="my-2" />
|
||||
{renderDetailTab()}
|
||||
|
||||
{/* 조건부 표시 설정 */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">조건부 표시</h4>
|
||||
</div>
|
||||
<div className="rounded-md border border-gray-200 p-2">
|
||||
<ConditionalConfigPanel
|
||||
config={
|
||||
(selectedComponent as any).conditional || {
|
||||
enabled: false,
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
action: "show",
|
||||
}
|
||||
}
|
||||
onChange={(newConfig: ConditionalConfig | undefined) => {
|
||||
handleUpdate("conditional", newConfig);
|
||||
}}
|
||||
availableFields={
|
||||
allComponents
|
||||
?.filter((c) => {
|
||||
// 자기 자신 제외
|
||||
if (c.id === selectedComponent.id) return false;
|
||||
// widget 타입 또는 component 타입 (Unified 컴포넌트 포함)
|
||||
return c.type === "widget" || c.type === "component";
|
||||
})
|
||||
.map((c) => {
|
||||
const widgetType = (c as any).widgetType || (c as any).componentType || "text";
|
||||
const config = (c as any).componentConfig || (c as any).webTypeConfig || {};
|
||||
const detailSettings = (c as any).detailSettings || {};
|
||||
|
||||
// 정적 옵션 추출 (select, dropdown, radio, entity 등)
|
||||
let options: Array<{ value: string; label: string }> | undefined;
|
||||
|
||||
// Unified 컴포넌트의 경우
|
||||
if (config.options && Array.isArray(config.options)) {
|
||||
options = config.options;
|
||||
}
|
||||
// 레거시 컴포넌트의 경우
|
||||
else if ((c as any).options && Array.isArray((c as any).options)) {
|
||||
options = (c as any).options;
|
||||
}
|
||||
|
||||
// 엔티티 정보 추출 (config > detailSettings > 직접 속성 순으로 우선순위)
|
||||
const entityTable =
|
||||
config.entityTable ||
|
||||
detailSettings.referenceTable ||
|
||||
(c as any).entityTable ||
|
||||
(c as any).referenceTable;
|
||||
const entityValueColumn =
|
||||
config.entityValueColumn ||
|
||||
detailSettings.referenceColumn ||
|
||||
(c as any).entityValueColumn ||
|
||||
(c as any).referenceColumn;
|
||||
const entityLabelColumn =
|
||||
config.entityLabelColumn ||
|
||||
detailSettings.displayColumn ||
|
||||
(c as any).entityLabelColumn ||
|
||||
(c as any).displayColumn;
|
||||
|
||||
// 공통코드 정보 추출
|
||||
const codeGroup = config.codeGroup || detailSettings.codeGroup || (c as any).codeGroup;
|
||||
|
||||
return {
|
||||
id: (c as any).columnName || c.id,
|
||||
label: (c as any).label || config.label || c.id,
|
||||
type: widgetType,
|
||||
options,
|
||||
entityTable,
|
||||
entityValueColumn,
|
||||
entityLabelColumn,
|
||||
codeGroup,
|
||||
};
|
||||
}) || []
|
||||
}
|
||||
currentComponentId={selectedComponent.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
{selectedComponent && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, ArrowLeft, Save, Monitor, Smartphone, Languages, Settings2 } from "lucide-react";
|
||||
import { ScreenResolution } from "@/types/screen";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Database,
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Grid3X3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Zap,
|
||||
Languages,
|
||||
Settings2,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface GridSettings {
|
||||
columns: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
snapToGrid: boolean;
|
||||
showGrid: boolean;
|
||||
gridColor?: string;
|
||||
gridOpacity?: number;
|
||||
}
|
||||
|
||||
interface SlimToolbarProps {
|
||||
screenName?: string;
|
||||
|
|
@ -13,9 +56,15 @@ interface SlimToolbarProps {
|
|||
onSave: () => void;
|
||||
isSaving?: boolean;
|
||||
onPreview?: () => void;
|
||||
onResolutionChange?: (resolution: ScreenResolution) => void;
|
||||
gridSettings?: GridSettings;
|
||||
onGridSettingsChange?: (settings: GridSettings) => void;
|
||||
onGenerateMultilang?: () => void;
|
||||
isGeneratingMultilang?: boolean;
|
||||
onOpenMultilangSettings?: () => void;
|
||||
// 패널 토글 기능
|
||||
isPanelOpen?: boolean;
|
||||
onTogglePanel?: () => void;
|
||||
}
|
||||
|
||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
|
|
@ -26,19 +75,86 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
onSave,
|
||||
isSaving = false,
|
||||
onPreview,
|
||||
onResolutionChange,
|
||||
gridSettings,
|
||||
onGridSettingsChange,
|
||||
onGenerateMultilang,
|
||||
isGeneratingMultilang = false,
|
||||
onOpenMultilangSettings,
|
||||
isPanelOpen = false,
|
||||
onTogglePanel,
|
||||
}) => {
|
||||
// 사용자 정의 해상도 상태
|
||||
const [customWidth, setCustomWidth] = useState("");
|
||||
const [customHeight, setCustomHeight] = useState("");
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case "desktop":
|
||||
return <Monitor className="h-4 w-4 text-blue-600" />;
|
||||
case "tablet":
|
||||
return <Tablet className="h-4 w-4 text-green-600" />;
|
||||
case "mobile":
|
||||
return <Smartphone className="h-4 w-4 text-purple-600" />;
|
||||
default:
|
||||
return <Monitor className="h-4 w-4 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomResolution = () => {
|
||||
const width = parseInt(customWidth);
|
||||
const height = parseInt(customHeight);
|
||||
if (width > 0 && height > 0 && onResolutionChange) {
|
||||
const customResolution: ScreenResolution = {
|
||||
width,
|
||||
height,
|
||||
name: `사용자 정의 (${width}×${height})`,
|
||||
category: "custom",
|
||||
};
|
||||
onResolutionChange(customResolution);
|
||||
setShowCustomInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateGridSetting = (key: keyof GridSettings, value: boolean) => {
|
||||
if (onGridSettingsChange && gridSettings) {
|
||||
onGridSettingsChange({
|
||||
...gridSettings,
|
||||
[key]: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
|
||||
{onTogglePanel && <div className="h-6 w-px bg-gray-300" />}
|
||||
|
||||
{/* 패널 토글 버튼 */}
|
||||
{onTogglePanel && (
|
||||
<Button
|
||||
variant={isPanelOpen ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={onTogglePanel}
|
||||
className="flex items-center space-x-2"
|
||||
title="패널 열기/닫기 (P)"
|
||||
>
|
||||
{isPanelOpen ? (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
)}
|
||||
<span>패널</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
|
|
@ -53,16 +169,149 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 해상도 정보 표시 */}
|
||||
{/* 해상도 선택 드롭다운 */}
|
||||
{screenResolution && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5">
|
||||
<Monitor className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
({screenResolution.width} × {screenResolution.height})
|
||||
</span>
|
||||
<Popover open={showCustomInput} onOpenChange={setShowCustomInput}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1.5 transition-colors hover:bg-blue-100">
|
||||
{getCategoryIcon(screenResolution.category || "desktop")}
|
||||
<span className="text-sm font-medium text-blue-900">{screenResolution.name}</span>
|
||||
<span className="text-xs text-blue-600">
|
||||
({screenResolution.width} × {screenResolution.height})
|
||||
</span>
|
||||
{onResolutionChange && <ChevronDown className="h-3 w-3 text-blue-600" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{onResolutionChange && (
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs text-gray-500">데스크톱</DropdownMenuLabel>
|
||||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
|
||||
<DropdownMenuItem
|
||||
key={resolution.name}
|
||||
onClick={() => onResolutionChange(resolution)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-blue-600" />
|
||||
<span className="flex-1">{resolution.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{resolution.width}×{resolution.height}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-gray-500">태블릿</DropdownMenuLabel>
|
||||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
|
||||
<DropdownMenuItem
|
||||
key={resolution.name}
|
||||
onClick={() => onResolutionChange(resolution)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Tablet className="h-4 w-4 text-green-600" />
|
||||
<span className="flex-1">{resolution.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{resolution.width}×{resolution.height}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-gray-500">모바일</DropdownMenuLabel>
|
||||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
|
||||
<DropdownMenuItem
|
||||
key={resolution.name}
|
||||
onClick={() => onResolutionChange(resolution)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 text-purple-600" />
|
||||
<span className="flex-1">{resolution.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{resolution.width}×{resolution.height}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-gray-500">사용자 정의</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCustomWidth(screenResolution.width.toString());
|
||||
setCustomHeight(screenResolution.height.toString());
|
||||
setShowCustomInput(true);
|
||||
}}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-gray-600" />
|
||||
<span className="flex-1">사용자 정의...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
<PopoverContent align="start" className="w-64 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">사용자 정의 해상도</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => setCustomWidth(e.target.value)}
|
||||
placeholder="1920"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => setCustomHeight(e.target.value)}
|
||||
placeholder="1080"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCustomResolution} size="sm" className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 격자 설정 */}
|
||||
{gridSettings && onGridSettingsChange && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div className="flex items-center space-x-2 rounded-md bg-gray-50 px-3 py-1.5">
|
||||
<Grid3X3 className="h-4 w-4 text-gray-600" />
|
||||
<div className="flex items-center space-x-3">
|
||||
<label className="flex cursor-pointer items-center space-x-1.5">
|
||||
{gridSettings.showGrid ? (
|
||||
<Eye className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5 text-gray-400" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={gridSettings.showGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("showGrid", checked as boolean)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">격자 표시</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center space-x-1.5">
|
||||
<Zap className={`h-3.5 w-3.5 ${gridSettings.snapToGrid ? "text-primary" : "text-gray-400"}`} />
|
||||
<Checkbox
|
||||
checked={gridSettings.snapToGrid}
|
||||
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked as boolean)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">격자 스냅</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
import { X } from "lucide-react";
|
||||
import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
|
||||
interface TabsWidgetProps {
|
||||
component: TabsComponent;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
menuObjid?: number; // 부모 화면의 메뉴 OBJID
|
||||
menuObjid?: number;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (data: Record<string, any>) => void;
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백
|
||||
selectedComponentId?: string; // 선택된 컴포넌트 ID
|
||||
}
|
||||
|
||||
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
||||
// ActiveTab context 사용
|
||||
export function TabsWidget({
|
||||
component,
|
||||
className,
|
||||
style,
|
||||
menuObjid,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
isDesignMode = false,
|
||||
onComponentSelect,
|
||||
selectedComponentId,
|
||||
}: TabsWidgetProps) {
|
||||
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
||||
const {
|
||||
tabs = [],
|
||||
|
|
@ -28,7 +42,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
persistSelection = false,
|
||||
} = component;
|
||||
|
||||
|
||||
const storageKey = `tabs-${component.id}-selected`;
|
||||
|
||||
// 초기 선택 탭 결정
|
||||
|
|
@ -44,9 +57,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
|
||||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
||||
// 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱)
|
||||
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
|
||||
|
||||
// 컴포넌트 탭 목록 변경 시 동기화
|
||||
|
|
@ -60,13 +70,11 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
localStorage.setItem(storageKey, selectedTab);
|
||||
}
|
||||
|
||||
// ActiveTab Context에 현재 활성 탭 정보 등록
|
||||
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
|
||||
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
||||
if (currentTabInfo) {
|
||||
setActiveTab(component.id, {
|
||||
tabId: selectedTab,
|
||||
tabsComponentId: component.id,
|
||||
screenId: currentTabInfo.screenId,
|
||||
label: currentTabInfo.label,
|
||||
});
|
||||
}
|
||||
|
|
@ -79,53 +87,16 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
};
|
||||
}, [component.id, removeTabsComponent]);
|
||||
|
||||
// 초기 로드 시 선택된 탭의 화면 불러오기
|
||||
useEffect(() => {
|
||||
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
||||
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
||||
loadScreenLayout(currentTab.screenId);
|
||||
}
|
||||
}, [selectedTab, visibleTabs]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
const loadScreenLayout = async (screenId: number) => {
|
||||
if (screenLayouts[screenId]) {
|
||||
return; // 이미 로드됨
|
||||
}
|
||||
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
||||
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
|
||||
} finally {
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setSelectedTab(tabId);
|
||||
|
||||
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
||||
setMountedTabs(prev => {
|
||||
setMountedTabs((prev) => {
|
||||
if (prev.has(tabId)) return prev;
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(tabId);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// 해당 탭의 화면 로드
|
||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
||||
loadScreenLayout(tab.screenId);
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 닫기 핸들러
|
||||
|
|
@ -135,7 +106,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
|
||||
setVisibleTabs(updatedTabs);
|
||||
|
||||
// 닫은 탭이 선택된 탭이었다면 다음 탭 선택
|
||||
if (selectedTab === tabId && updatedTabs.length > 0) {
|
||||
setSelectedTab(updatedTabs[0].id);
|
||||
}
|
||||
|
|
@ -153,6 +123,84 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
return `${baseClass} ${variantClass}`;
|
||||
};
|
||||
|
||||
// 인라인 컴포넌트 렌더링
|
||||
const renderTabComponents = (tab: TabItem) => {
|
||||
const components = tab.components || [];
|
||||
|
||||
if (components.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
300 // 최소 높이
|
||||
);
|
||||
const maxRight = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400 // 최소 너비
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
minHeight: maxBottom + 20,
|
||||
minWidth: maxRight + 20,
|
||||
}}
|
||||
>
|
||||
{components.map((comp: TabInlineComponent) => {
|
||||
const isSelected = selectedComponentId === comp.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className={cn(
|
||||
"absolute",
|
||||
isDesignMode && "cursor-move",
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
style={{
|
||||
left: comp.position?.x || 0,
|
||||
top: comp.position?.y || 0,
|
||||
width: comp.size?.width || 200,
|
||||
height: comp.size?.height || 100,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (isDesignMode && onComponentSelect) {
|
||||
e.stopPropagation();
|
||||
onComponentSelect(tab.id, comp.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
id: comp.id,
|
||||
componentType: comp.componentType,
|
||||
label: comp.label,
|
||||
position: comp.position,
|
||||
size: comp.size,
|
||||
componentConfig: comp.componentConfig || {},
|
||||
style: comp.style,
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
|
|
@ -162,7 +210,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
||||
<div className={cn("flex h-full w-full flex-col pt-4", className)} style={style}>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={handleTabChange}
|
||||
|
|
@ -175,6 +223,11 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
<div key={tab.id} className="relative">
|
||||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||
{tab.label}
|
||||
{tab.components && tab.components.length > 0 && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">
|
||||
({tab.components.length})
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
{allowCloseable && (
|
||||
<Button
|
||||
|
|
@ -191,10 +244,8 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="relative flex-1 overflow-auto">
|
||||
{visibleTabs.map((tab) => {
|
||||
// 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩)
|
||||
const shouldRender = mountedTabs.has(tab.id);
|
||||
const isActive = selectedTab === tab.id;
|
||||
|
||||
|
|
@ -202,75 +253,10 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
|||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
forceMount // 🆕 DOM에 항상 유지
|
||||
className={cn(
|
||||
"h-full",
|
||||
!isActive && "hidden" // 🆕 비활성 탭은 CSS로 숨김
|
||||
)}
|
||||
forceMount
|
||||
className={cn("h-full overflow-auto", !isActive && "hidden")}
|
||||
>
|
||||
{/* 한 번 마운트된 탭만 내용 렌더링 */}
|
||||
{shouldRender && (
|
||||
<>
|
||||
{tab.screenId ? (
|
||||
loadingScreens[tab.screenId] ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
||||
</div>
|
||||
) : screenLayouts[tab.screenId] ? (
|
||||
(() => {
|
||||
const layoutData = screenLayouts[tab.screenId];
|
||||
const { components = [], screenResolution } = layoutData;
|
||||
|
||||
|
||||
const designWidth = screenResolution?.width || 1920;
|
||||
const designHeight = screenResolution?.height || 1080;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto bg-background"
|
||||
style={{
|
||||
minHeight: `${designHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{components.map((comp: any) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
screenInfo={{
|
||||
id: tab.screenId,
|
||||
tableName: layoutData.tableName,
|
||||
}}
|
||||
menuObjid={menuObjid}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{shouldRender && renderTabComponents(tab)}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,96 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
useEffect(() => {
|
||||
if (menuObjid) {
|
||||
loadCategoryColumnsByMenu();
|
||||
} else if (tableName) {
|
||||
// menuObjid가 없으면 tableName 기반으로 조회
|
||||
loadCategoryColumnsByTable();
|
||||
} else {
|
||||
console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다");
|
||||
console.warn("⚠️ menuObjid와 tableName 모두 없어서 카테고리 컬럼을 로드할 수 없습니다");
|
||||
setColumns([]);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
}, [menuObjid, tableName]);
|
||||
|
||||
// tableName 기반으로 카테고리 컬럼 조회
|
||||
const loadCategoryColumnsByTable = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("🔍 테이블 기반 카테고리 컬럼 조회 시작", { tableName });
|
||||
|
||||
// table_type_columns에서 input_type='category'인 컬럼 조회
|
||||
const response = await apiClient.get(`/screen-management/tables/${tableName}/columns`);
|
||||
|
||||
console.log("✅ 테이블 컬럼 API 응답:", response.data);
|
||||
|
||||
let allColumns: any[] = [];
|
||||
if (response.data.success && response.data.data) {
|
||||
allColumns = response.data.data;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
allColumns = response.data;
|
||||
}
|
||||
|
||||
// category 타입 컬럼만 필터링
|
||||
const categoryColumns = allColumns.filter(
|
||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
||||
);
|
||||
|
||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||
total: allColumns.length,
|
||||
categoryCount: categoryColumns.length,
|
||||
});
|
||||
|
||||
// 값 개수 조회 (테스트 테이블 사용)
|
||||
const columnsWithCount = await Promise.all(
|
||||
categoryColumns.map(async (col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
const colLabel = col.columnLabel || col.column_label || colName;
|
||||
|
||||
let valueCount = 0;
|
||||
try {
|
||||
// 테스트 테이블에서 조회
|
||||
const treeResponse = await apiClient.get(`/category-tree/test/${tableName}/${colName}`);
|
||||
if (treeResponse.data.success && treeResponse.data.data) {
|
||||
valueCount = countTreeNodes(treeResponse.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`항목 개수 조회 실패 (${tableName}.${colName}):`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
tableLabel: tableName,
|
||||
columnName: colName,
|
||||
columnLabel: colLabel,
|
||||
inputType: "category",
|
||||
valueCount,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setColumns(columnsWithCount);
|
||||
|
||||
// 첫 번째 컬럼 자동 선택
|
||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||
const firstCol = columnsWithCount[0];
|
||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 트리 노드 수 계산 함수
|
||||
const countTreeNodes = (nodes: any[]): number => {
|
||||
let count = nodes.length;
|
||||
for (const node of nodes) {
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
count += countTreeNodes(node.children);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const loadCategoryColumnsByMenu = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -99,6 +184,13 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
}),
|
||||
);
|
||||
|
||||
// 결과가 0개면 tableName 기반으로 fallback
|
||||
if (columnsWithCount.length === 0 && tableName) {
|
||||
console.log("⚠️ menuObjid 기반 조회 결과 없음, tableName 기반으로 fallback:", tableName);
|
||||
await loadCategoryColumnsByTable();
|
||||
return;
|
||||
}
|
||||
|
||||
setColumns(columnsWithCount);
|
||||
|
||||
// 첫 번째 컬럼 자동 선택
|
||||
|
|
@ -108,10 +200,16 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 에러 시에도 tableName 기반으로 fallback
|
||||
if (tableName) {
|
||||
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
|
||||
await loadCategoryColumnsByTable();
|
||||
return;
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,720 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 카테고리 값 관리 - 트리 구조 버전
|
||||
* - 3단계 트리 구조 지원 (대분류/중분류/소분류)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Tag,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CategoryValue,
|
||||
getCategoryTree,
|
||||
createCategoryValue,
|
||||
updateCategoryValue,
|
||||
deleteCategoryValue,
|
||||
CreateCategoryValueInput,
|
||||
} from "@/lib/api/categoryTree";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface CategoryValueManagerTreeProps {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
onValueCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
// 트리 노드 컴포넌트
|
||||
interface TreeNodeProps {
|
||||
node: CategoryValue;
|
||||
level: number;
|
||||
expandedNodes: Set<number>;
|
||||
selectedValueId?: number;
|
||||
searchQuery: string;
|
||||
onToggle: (valueId: number) => void;
|
||||
onSelect: (value: CategoryValue) => void;
|
||||
onAdd: (parentValue: CategoryValue | null) => void;
|
||||
onEdit: (value: CategoryValue) => void;
|
||||
onDelete: (value: CategoryValue) => void;
|
||||
}
|
||||
|
||||
// 검색어가 노드 또는 하위에 매칭되는지 확인
|
||||
const nodeMatchesSearch = (node: CategoryValue, query: string): boolean => {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
if (node.valueLabel.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (node.valueCode.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (node.children) {
|
||||
return node.children.some((child) => nodeMatchesSearch(child, query));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
level,
|
||||
expandedNodes,
|
||||
selectedValueId,
|
||||
searchQuery,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedNodes.has(node.valueId);
|
||||
const isSelected = selectedValueId === node.valueId;
|
||||
const canAddChild = node.depth < 3;
|
||||
|
||||
// 검색 필터링
|
||||
if (searchQuery && !nodeMatchesSearch(node, searchQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 깊이별 아이콘
|
||||
const getIcon = () => {
|
||||
if (hasChildren) {
|
||||
return isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-amber-500" />
|
||||
);
|
||||
}
|
||||
return <Tag className="h-4 w-4 text-blue-500" />;
|
||||
};
|
||||
|
||||
// 깊이별 라벨
|
||||
const getDepthLabel = () => {
|
||||
switch (node.depth) {
|
||||
case 1:
|
||||
return "대분류";
|
||||
case 2:
|
||||
return "중분류";
|
||||
case 3:
|
||||
return "소분류";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
|
||||
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||
onClick={() => onSelect(node)}
|
||||
>
|
||||
{/* 확장 토글 */}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasChildren) {
|
||||
onToggle(node.valueId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 아이콘 */}
|
||||
{getIcon()}
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
|
||||
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
|
||||
</div>
|
||||
|
||||
{/* 비활성 표시 */}
|
||||
{!node.isActive && (
|
||||
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]">비활성</span>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{canAddChild && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd(node);
|
||||
}}
|
||||
title="하위 추가"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(node);
|
||||
}}
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node);
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.valueId}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedValueId={selectedValueId}
|
||||
searchQuery={searchQuery}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> = ({
|
||||
tableName,
|
||||
columnName,
|
||||
columnLabel,
|
||||
onValueCountChange,
|
||||
}) => {
|
||||
// 상태
|
||||
const [tree, setTree] = useState<CategoryValue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// 전체 값 개수 계산
|
||||
const countAllValues = useCallback((nodes: CategoryValue[]): number => {
|
||||
let count = nodes.length;
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
count += countAllValues(node.children);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
// 활성 노드만 필터링
|
||||
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
||||
return nodes
|
||||
.filter((node) => node.isActive !== false)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? filterActiveNodes(node.children) : undefined,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadTree = useCallback(async () => {
|
||||
if (!tableName || !columnName) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
let filteredTree = response.data;
|
||||
|
||||
// 비활성 필터링
|
||||
if (!showInactive) {
|
||||
filteredTree = filterActiveNodes(response.data);
|
||||
}
|
||||
|
||||
setTree(filteredTree);
|
||||
|
||||
// 1단계 노드는 기본 펼침
|
||||
const rootIds = new Set(filteredTree.map((n) => n.valueId));
|
||||
setExpandedNodes(rootIds);
|
||||
|
||||
// 전체 개수 업데이트
|
||||
const totalCount = countAllValues(response.data);
|
||||
onValueCountChange?.(totalCount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 트리 로드 오류:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
// 모든 노드 펼치기
|
||||
const expandAll = () => {
|
||||
const allIds = new Set<number>();
|
||||
const collectIds = (nodes: CategoryValue[]) => {
|
||||
for (const node of nodes) {
|
||||
allIds.add(node.valueId);
|
||||
if (node.children) {
|
||||
collectIds(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectIds(tree);
|
||||
setExpandedNodes(allIds);
|
||||
};
|
||||
|
||||
// 모든 노드 접기
|
||||
const collapseAll = () => {
|
||||
setExpandedNodes(new Set());
|
||||
};
|
||||
|
||||
// 토글 핸들러
|
||||
const handleToggle = (valueId: number) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(valueId)) {
|
||||
next.delete(valueId);
|
||||
} else {
|
||||
next.add(valueId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 추가 모달 열기
|
||||
const handleOpenAddModal = (parent: CategoryValue | null) => {
|
||||
setParentValue(parent);
|
||||
setFormData({
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
isActive: true,
|
||||
});
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const handleOpenEditModal = (value: CategoryValue) => {
|
||||
setEditingValue(value);
|
||||
setFormData({
|
||||
valueCode: value.valueCode,
|
||||
valueLabel: value.valueLabel,
|
||||
description: value.description || "",
|
||||
color: value.color || "",
|
||||
isActive: value.isActive,
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 다이얼로그 열기
|
||||
const handleOpenDeleteDialog = (value: CategoryValue) => {
|
||||
setDeletingValue(value);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 코드 자동 생성 함수
|
||||
const generateCode = () => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `CAT_${timestamp}_${random}`;
|
||||
};
|
||||
|
||||
// 추가 처리
|
||||
const handleAdd = async () => {
|
||||
if (!formData.valueLabel) {
|
||||
toast.error("이름은 필수입니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 코드 자동 생성
|
||||
const autoCode = generateCode();
|
||||
|
||||
const input: CreateCategoryValueInput = {
|
||||
tableName,
|
||||
columnName,
|
||||
valueCode: autoCode,
|
||||
valueLabel: formData.valueLabel,
|
||||
parentValueId: parentValue?.valueId || null,
|
||||
description: formData.description || undefined,
|
||||
color: formData.color || undefined,
|
||||
isActive: formData.isActive,
|
||||
};
|
||||
|
||||
const response = await createCategoryValue(input);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
setIsAddModalOpen(false);
|
||||
loadTree();
|
||||
if (parentValue) {
|
||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
||||
}
|
||||
} else {
|
||||
toast.error(response.error || "추가 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 추가 오류:", error);
|
||||
toast.error("카테고리 추가 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 처리
|
||||
const handleEdit = async () => {
|
||||
if (!editingValue) return;
|
||||
|
||||
try {
|
||||
// 코드는 변경하지 않음 (기존 코드 유지)
|
||||
const response = await updateCategoryValue(editingValue.valueId, {
|
||||
valueLabel: formData.valueLabel,
|
||||
description: formData.description || undefined,
|
||||
color: formData.color || undefined,
|
||||
isActive: formData.isActive,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 수정되었습니다");
|
||||
setIsEditModalOpen(false);
|
||||
loadTree();
|
||||
} else {
|
||||
toast.error(response.error || "수정 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 수정 오류:", error);
|
||||
toast.error("카테고리 수정 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 처리
|
||||
const handleDelete = async () => {
|
||||
if (!deletingValue) return;
|
||||
|
||||
try {
|
||||
const response = await deleteCategoryValue(deletingValue.valueId);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 삭제되었습니다");
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedValue(null);
|
||||
loadTree();
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 삭제 오류:", error);
|
||||
toast.error("카테고리 삭제 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between border-b pb-3">
|
||||
<h3 className="text-base font-semibold">{columnLabel} 카테고리</h3>
|
||||
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
대분류 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* 검색 */}
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="showInactive" checked={showInactive} onCheckedChange={setShowInactive} />
|
||||
<Label htmlFor="showInactive" className="cursor-pointer text-xs">
|
||||
비활성 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
|
||||
전체 펼침
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
|
||||
전체 접기
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadTree} title="새로고침">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="bg-card min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||
</div>
|
||||
) : tree.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Folder className="text-muted-foreground/30 mb-3 h-12 w-12" />
|
||||
<p className="text-muted-foreground text-sm">카테고리가 없습니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">상단의 대분류 추가 버튼을 클릭하여 시작하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.valueId}
|
||||
node={node}
|
||||
level={0}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedValueId={selectedValue?.valueId}
|
||||
searchQuery={searchQuery}
|
||||
onToggle={handleToggle}
|
||||
onSelect={setSelectedValue}
|
||||
onAdd={handleOpenAddModal}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteDialog}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 추가 모달 */}
|
||||
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="valueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
placeholder="카테고리 이름을 입력하세요"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">코드는 자동으로 생성됩니다</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="선택 사항"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive" className="cursor-pointer text-sm">
|
||||
활성 상태
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수정 모달 */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">카테고리 수정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">카테고리 정보를 수정합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="editValueLabel" className="text-xs sm:text-sm">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="editValueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="editDescription" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Input
|
||||
id="editDescription"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="editIsActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="editIsActive" className="cursor-pointer text-sm">
|
||||
활성 상태
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
||||
{deletingValue?.children && deletingValue.children.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
하위 카테고리 {deletingValue.children.length}개도 모두 함께 삭제됩니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryValueManagerTree;
|
||||
|
||||
|
|
@ -15,38 +15,48 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
// react-day-picker v9 클래스명
|
||||
months: "flex flex-col sm:flex-row gap-4",
|
||||
month: "flex flex-col gap-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
nav: "flex items-center gap-1",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex w-full",
|
||||
head_cell:
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1",
|
||||
),
|
||||
month_grid: "w-full border-collapse",
|
||||
weekdays: "flex w-full",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 h-9 font-normal text-[0.8rem] inline-flex items-center justify-center",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative",
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
range_end: "day-range-end",
|
||||
selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground rounded-md",
|
||||
today: "bg-accent text-accent-foreground rounded-md",
|
||||
outside:
|
||||
"text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
disabled: "text-muted-foreground opacity-50",
|
||||
range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
Chevron: ({ orientation }) => {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeft className="h-4 w-4" />;
|
||||
}
|
||||
return <ChevronRight className="h-4 w-4" />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,493 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ConditionalConfigPanel
|
||||
*
|
||||
* 비개발자도 쉽게 조건부 표시/숨김/활성화/비활성화를 설정할 수 있는 UI
|
||||
*
|
||||
* 사용처:
|
||||
* - 화면관리 > 상세설정 패널
|
||||
* - 화면관리 > 속성 패널
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Zap, Plus, Trash2, HelpCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ConditionalConfig } from "@/types/unified-components";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
|
||||
interface FieldOption {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string; // text, number, select, checkbox, entity, code 등
|
||||
options?: Array<{ value: string; label: string }>; // select 타입일 경우 옵션들
|
||||
// 동적 옵션 로드를 위한 정보
|
||||
entityTable?: string;
|
||||
entityValueColumn?: string;
|
||||
entityLabelColumn?: string;
|
||||
codeGroup?: string;
|
||||
}
|
||||
|
||||
interface ConditionalConfigPanelProps {
|
||||
/** 현재 조건부 설정 */
|
||||
config?: ConditionalConfig;
|
||||
/** 설정 변경 콜백 */
|
||||
onChange: (config: ConditionalConfig | undefined) => void;
|
||||
/** 같은 화면에 있는 다른 필드들 (조건 필드로 선택 가능) */
|
||||
availableFields: FieldOption[];
|
||||
/** 현재 컴포넌트 ID (자기 자신은 조건 필드에서 제외) */
|
||||
currentComponentId?: string;
|
||||
}
|
||||
|
||||
// 연산자 옵션
|
||||
const OPERATORS: Array<{ value: ConditionalConfig["operator"]; label: string; description: string }> = [
|
||||
{ value: "=", label: "같음", description: "값이 정확히 일치할 때" },
|
||||
{ value: "!=", label: "다름", description: "값이 일치하지 않을 때" },
|
||||
{ value: ">", label: "보다 큼", description: "값이 더 클 때 (숫자)" },
|
||||
{ value: "<", label: "보다 작음", description: "값이 더 작을 때 (숫자)" },
|
||||
{ value: "in", label: "포함됨", description: "여러 값 중 하나일 때" },
|
||||
{ value: "notIn", label: "포함 안됨", description: "여러 값 중 아무것도 아닐 때" },
|
||||
{ value: "isEmpty", label: "비어있음", description: "값이 없을 때" },
|
||||
{ value: "isNotEmpty", label: "값이 있음", description: "값이 있을 때" },
|
||||
];
|
||||
|
||||
// 동작 옵션
|
||||
const ACTIONS: Array<{ value: ConditionalConfig["action"]; label: string; description: string }> = [
|
||||
{ value: "show", label: "표시", description: "조건 만족 시 이 필드를 표시" },
|
||||
{ value: "hide", label: "숨김", description: "조건 만족 시 이 필드를 숨김" },
|
||||
{ value: "enable", label: "활성화", description: "조건 만족 시 이 필드를 활성화" },
|
||||
{ value: "disable", label: "비활성화", description: "조건 만족 시 이 필드를 비활성화" },
|
||||
];
|
||||
|
||||
// ===== 컴포넌트 =====
|
||||
|
||||
export function ConditionalConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
availableFields,
|
||||
currentComponentId,
|
||||
}: ConditionalConfigPanelProps) {
|
||||
// 로컬 상태
|
||||
const [enabled, setEnabled] = useState(config?.enabled ?? false);
|
||||
const [field, setField] = useState(config?.field ?? "");
|
||||
const [operator, setOperator] = useState<ConditionalConfig["operator"]>(config?.operator ?? "=");
|
||||
const [value, setValue] = useState<string>(String(config?.value ?? ""));
|
||||
const [action, setAction] = useState<ConditionalConfig["action"]>(config?.action ?? "show");
|
||||
|
||||
// 자기 자신을 제외한 필드 목록
|
||||
const selectableFields = useMemo(() => {
|
||||
return availableFields.filter((f) => f.id !== currentComponentId);
|
||||
}, [availableFields, currentComponentId]);
|
||||
|
||||
// 선택된 필드 정보
|
||||
const selectedField = useMemo(() => {
|
||||
return selectableFields.find((f) => f.id === field);
|
||||
}, [selectableFields, field]);
|
||||
|
||||
// 동적 옵션 로드 상태
|
||||
const [dynamicOptions, setDynamicOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
|
||||
// Combobox 열림 상태
|
||||
const [comboboxOpen, setComboboxOpen] = useState(false);
|
||||
|
||||
// 엔티티/공통코드 필드 선택 시 동적으로 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadDynamicOptions = async () => {
|
||||
if (!selectedField) {
|
||||
setDynamicOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 정적 옵션이 있으면 사용
|
||||
if (selectedField.options && selectedField.options.length > 0) {
|
||||
setDynamicOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 엔티티 타입 (타입이 entity이거나, entityTable이 있으면 엔티티로 간주)
|
||||
if (selectedField.entityTable) {
|
||||
setLoadingOptions(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const valueCol = selectedField.entityValueColumn || "id";
|
||||
const labelCol = selectedField.entityLabelColumn || "name";
|
||||
const response = await apiClient.get(`/entity/${selectedField.entityTable}/options`, {
|
||||
params: { value: valueCol, label: labelCol },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
setDynamicOptions(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("엔티티 옵션 로드 실패:", error);
|
||||
setDynamicOptions([]);
|
||||
} finally {
|
||||
setLoadingOptions(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 공통코드 타입 (타입이 code이거나, codeGroup이 있으면 공통코드로 간주)
|
||||
if (selectedField.codeGroup) {
|
||||
setLoadingOptions(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
// 올바른 API 경로: /common-codes/categories/:categoryCode/options
|
||||
const response = await apiClient.get(`/common-codes/categories/${selectedField.codeGroup}/options`);
|
||||
if (response.data.success && response.data.data) {
|
||||
setDynamicOptions(
|
||||
response.data.data.map((item: { value: string; label: string }) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("공통코드 옵션 로드 실패:", error);
|
||||
setDynamicOptions([]);
|
||||
} finally {
|
||||
setLoadingOptions(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setDynamicOptions([]);
|
||||
};
|
||||
|
||||
loadDynamicOptions();
|
||||
}, [selectedField?.id, selectedField?.entityTable, selectedField?.entityValueColumn, selectedField?.entityLabelColumn, selectedField?.codeGroup]);
|
||||
|
||||
// 최종 옵션 (정적 + 동적)
|
||||
const fieldOptions = useMemo(() => {
|
||||
if (selectedField?.options && selectedField.options.length > 0) {
|
||||
return selectedField.options;
|
||||
}
|
||||
return dynamicOptions;
|
||||
}, [selectedField?.options, dynamicOptions]);
|
||||
|
||||
// config prop 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setEnabled(config?.enabled ?? false);
|
||||
setField(config?.field ?? "");
|
||||
setOperator(config?.operator ?? "=");
|
||||
setValue(String(config?.value ?? ""));
|
||||
setAction(config?.action ?? "show");
|
||||
}, [config]);
|
||||
|
||||
// 설정 변경 시 부모에게 알림
|
||||
const updateConfig = (updates: Partial<ConditionalConfig>) => {
|
||||
const newConfig: ConditionalConfig = {
|
||||
enabled: updates.enabled ?? enabled,
|
||||
field: updates.field ?? field,
|
||||
operator: updates.operator ?? operator,
|
||||
value: updates.value ?? value,
|
||||
action: updates.action ?? action,
|
||||
};
|
||||
|
||||
// enabled가 false이면 undefined 반환 (설정 제거)
|
||||
if (!newConfig.enabled) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// 활성화 토글
|
||||
const handleEnabledChange = (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
updateConfig({ enabled: checked });
|
||||
};
|
||||
|
||||
// 조건 필드 변경
|
||||
const handleFieldChange = (newField: string) => {
|
||||
setField(newField);
|
||||
setValue(""); // 필드 변경 시 값 초기화
|
||||
updateConfig({ field: newField, value: "" });
|
||||
};
|
||||
|
||||
// 연산자 변경
|
||||
const handleOperatorChange = (newOperator: ConditionalConfig["operator"]) => {
|
||||
setOperator(newOperator);
|
||||
// 비어있음/값이있음 연산자는 value 필요 없음
|
||||
if (newOperator === "isEmpty" || newOperator === "isNotEmpty") {
|
||||
setValue("");
|
||||
updateConfig({ operator: newOperator, value: "" });
|
||||
} else {
|
||||
updateConfig({ operator: newOperator });
|
||||
}
|
||||
};
|
||||
|
||||
// 값 변경
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setValue(newValue);
|
||||
|
||||
// 타입에 따라 적절한 값으로 변환
|
||||
let parsedValue: unknown = newValue;
|
||||
if (selectedField?.type === "number") {
|
||||
parsedValue = Number(newValue);
|
||||
} else if (newValue === "true") {
|
||||
parsedValue = true;
|
||||
} else if (newValue === "false") {
|
||||
parsedValue = false;
|
||||
}
|
||||
|
||||
updateConfig({ value: parsedValue });
|
||||
};
|
||||
|
||||
// 동작 변경
|
||||
const handleActionChange = (newAction: ConditionalConfig["action"]) => {
|
||||
setAction(newAction);
|
||||
updateConfig({ action: newAction });
|
||||
};
|
||||
|
||||
// 값 입력 필드 렌더링 (필드 타입에 따라 다르게)
|
||||
const renderValueInput = () => {
|
||||
// 비어있음/값이있음은 값 입력 불필요
|
||||
if (operator === "isEmpty" || operator === "isNotEmpty") {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
(값 입력 불필요)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 옵션 로딩 중
|
||||
if (loadingOptions) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
옵션 로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 옵션이 있으면 검색 가능한 Combobox로 표시
|
||||
if (fieldOptions.length > 0) {
|
||||
const selectedOption = fieldOptions.find((opt) => opt.value === value);
|
||||
|
||||
return (
|
||||
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={comboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : "값 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
검색 결과가 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{fieldOptions.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt.value}
|
||||
value={opt.label}
|
||||
onSelect={() => {
|
||||
handleValueChange(opt.value);
|
||||
setComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
value === opt.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 체크박스 타입이면 true/false Select
|
||||
if (selectedField?.type === "checkbox" || selectedField?.type === "boolean") {
|
||||
return (
|
||||
<Select value={value} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true" className="text-xs">체크됨</SelectItem>
|
||||
<SelectItem value="false" className="text-xs">체크 안됨</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 숫자 타입
|
||||
if (selectedField?.type === "number") {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본: 텍스트 입력
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder="값 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium">조건부 표시</span>
|
||||
<span
|
||||
className="text-muted-foreground cursor-help"
|
||||
title="다른 필드의 값에 따라 이 필드를 표시/숨김/활성화/비활성화할 수 있습니다."
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleEnabledChange}
|
||||
aria-label="조건부 표시 활성화"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 영역 */}
|
||||
{enabled && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
{/* 조건 필드 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">조건 필드</Label>
|
||||
<Select value={field} onValueChange={handleFieldChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableFields.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground">
|
||||
선택 가능한 필드가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
selectableFields.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id} className="text-xs">
|
||||
{f.label || f.id}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
이 필드의 값에 따라 조건이 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">조건</Label>
|
||||
<Select value={operator} onValueChange={handleOperatorChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{op.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 값 입력 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">값</Label>
|
||||
{renderValueInput()}
|
||||
</div>
|
||||
|
||||
{/* 동작 선택 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">동작</Label>
|
||||
<Select value={action} onValueChange={handleActionChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIONS.map((act) => (
|
||||
<SelectItem key={act.value} value={act.value} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{act.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
조건이 만족되면 이 필드를 {ACTIONS.find(a => a.value === action)?.label}합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{field && (
|
||||
<div className="mt-3 rounded bg-slate-100 p-2">
|
||||
<p className="text-[10px] font-medium text-slate-600">설정 요약:</p>
|
||||
<p className="text-[11px] text-slate-800">
|
||||
"{selectableFields.find(f => f.id === field)?.label || field}" 필드가{" "}
|
||||
<span className="font-medium">
|
||||
{operator === "isEmpty" ? "비어있으면" :
|
||||
operator === "isNotEmpty" ? "값이 있으면" :
|
||||
`"${value}"${operator === "=" ? "이면" :
|
||||
operator === "!=" ? "이 아니면" :
|
||||
operator === ">" ? "보다 크면" :
|
||||
operator === "<" ? "보다 작으면" :
|
||||
operator === "in" ? "에 포함되면" : "에 포함되지 않으면"}`}
|
||||
</span>{" "}
|
||||
→ 이 필드를{" "}
|
||||
<span className="font-medium text-orange-600">
|
||||
{action === "show" ? "표시" :
|
||||
action === "hide" ? "숨김" :
|
||||
action === "enable" ? "활성화" : "비활성화"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionalConfigPanel;
|
||||
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DynamicConfigPanel
|
||||
*
|
||||
* JSON Schema 기반으로 동적으로 설정 UI를 생성하는 패널
|
||||
* 모든 Unified 컴포넌트의 설정을 단일 컴포넌트로 처리
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { JSONSchemaProperty, UnifiedConfigSchema } from "@/types/unified-components";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DynamicConfigPanelProps {
|
||||
schema: UnifiedConfigSchema;
|
||||
config: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 스키마 속성을 렌더링하는 컴포넌트
|
||||
*/
|
||||
function SchemaField({
|
||||
name,
|
||||
property,
|
||||
value,
|
||||
onChange,
|
||||
path = [],
|
||||
}: {
|
||||
name: string;
|
||||
property: JSONSchemaProperty;
|
||||
value: unknown;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
path?: string[];
|
||||
}) {
|
||||
const fieldPath = [...path, name].join(".");
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleChange = useCallback(
|
||||
(newValue: unknown) => {
|
||||
onChange(fieldPath, newValue);
|
||||
},
|
||||
[fieldPath, onChange]
|
||||
);
|
||||
|
||||
// 타입에 따른 컴포넌트 렌더링
|
||||
const renderField = () => {
|
||||
// enum이 있으면 Select 렌더링
|
||||
if (property.enum && property.enum.length > 0) {
|
||||
return (
|
||||
<Select
|
||||
value={String(value ?? property.default ?? "")}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{property.enum.map((option) => (
|
||||
<SelectItem key={option} value={option} className="text-xs">
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 타입별 렌더링
|
||||
switch (property.type) {
|
||||
case "string":
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value ?? property.default ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={property.description}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value !== undefined && value !== null ? Number(value) : ""}
|
||||
onChange={(e) => handleChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder={property.description}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(value ?? property.default ?? false)}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
);
|
||||
|
||||
case "array":
|
||||
// 배열은 간단한 텍스트 입력으로 처리 (쉼표 구분)
|
||||
return (
|
||||
<Textarea
|
||||
value={Array.isArray(value) ? value.join(", ") : ""}
|
||||
onChange={(e) => {
|
||||
const arr = e.target.value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
handleChange(arr);
|
||||
}}
|
||||
placeholder="쉼표로 구분하여 입력"
|
||||
className="text-xs min-h-[60px]"
|
||||
/>
|
||||
);
|
||||
|
||||
case "object":
|
||||
// 중첩 객체는 별도 섹션으로 렌더링
|
||||
if (property.properties) {
|
||||
return (
|
||||
<div className="mt-2 pl-4 border-l-2 border-muted space-y-3">
|
||||
{Object.entries(property.properties).map(([subName, subProp]) => (
|
||||
<SchemaField
|
||||
key={subName}
|
||||
name={subName}
|
||||
property={subProp}
|
||||
value={(value as Record<string, unknown>)?.[subName]}
|
||||
onChange={onChange}
|
||||
path={[...path, name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">
|
||||
{property.title || name}
|
||||
</Label>
|
||||
{property.type === "boolean" && renderField()}
|
||||
</div>
|
||||
{property.description && (
|
||||
<p className="text-[10px] text-muted-foreground">{property.description}</p>
|
||||
)}
|
||||
{property.type !== "boolean" && renderField()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 DynamicConfigPanel 컴포넌트
|
||||
*/
|
||||
export function DynamicConfigPanel({
|
||||
schema,
|
||||
config,
|
||||
onChange,
|
||||
className,
|
||||
}: DynamicConfigPanelProps) {
|
||||
// 속성들을 카테고리별로 그룹화
|
||||
const groupedProperties = useMemo(() => {
|
||||
const groups: Record<string, Array<[string, JSONSchemaProperty]>> = {
|
||||
기본: [],
|
||||
고급: [],
|
||||
스타일: [],
|
||||
};
|
||||
|
||||
Object.entries(schema.properties).forEach(([name, property]) => {
|
||||
// 이름 기반으로 그룹 분류
|
||||
if (name.includes("style") || name.includes("Style")) {
|
||||
groups["스타일"].push([name, property]);
|
||||
} else if (
|
||||
name.includes("cascade") ||
|
||||
name.includes("mutual") ||
|
||||
name.includes("conditional") ||
|
||||
name.includes("autoFill")
|
||||
) {
|
||||
groups["고급"].push([name, property]);
|
||||
} else {
|
||||
groups["기본"].push([name, property]);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [schema.properties]);
|
||||
|
||||
// 값 변경 핸들러 (중첩 경로 지원)
|
||||
const handleChange = useCallback(
|
||||
(path: string, value: unknown) => {
|
||||
onChange(path, value);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{Object.entries(groupedProperties).map(
|
||||
([groupName, properties]) =>
|
||||
properties.length > 0 && (
|
||||
<Collapsible key={groupName} defaultOpen={groupName === "기본"}>
|
||||
<Card>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
{groupName} 설정
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0 space-y-4">
|
||||
{properties.map(([name, property]) => (
|
||||
<SchemaField
|
||||
key={name}
|
||||
name={name}
|
||||
property={property}
|
||||
value={config[name]}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 스키마들 (자주 사용되는 설정)
|
||||
*/
|
||||
export const COMMON_SCHEMAS = {
|
||||
// UnifiedInput 기본 스키마
|
||||
UnifiedInput: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
type: {
|
||||
type: "string" as const,
|
||||
enum: ["text", "number", "password", "slider", "color", "button"],
|
||||
default: "text",
|
||||
title: "입력 타입",
|
||||
},
|
||||
format: {
|
||||
type: "string" as const,
|
||||
enum: ["none", "email", "tel", "url", "currency", "biz_no"],
|
||||
default: "none",
|
||||
title: "형식",
|
||||
},
|
||||
placeholder: {
|
||||
type: "string" as const,
|
||||
title: "플레이스홀더",
|
||||
},
|
||||
min: {
|
||||
type: "number" as const,
|
||||
title: "최소값",
|
||||
description: "숫자 타입 전용",
|
||||
},
|
||||
max: {
|
||||
type: "number" as const,
|
||||
title: "최대값",
|
||||
description: "숫자 타입 전용",
|
||||
},
|
||||
step: {
|
||||
type: "number" as const,
|
||||
title: "증가 단위",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// UnifiedSelect 기본 스키마
|
||||
UnifiedSelect: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string" as const,
|
||||
enum: ["dropdown", "radio", "check", "tag", "toggle", "swap"],
|
||||
default: "dropdown",
|
||||
title: "표시 모드",
|
||||
},
|
||||
source: {
|
||||
type: "string" as const,
|
||||
enum: ["static", "code", "db", "api", "entity"],
|
||||
default: "static",
|
||||
title: "데이터 소스",
|
||||
},
|
||||
codeGroup: {
|
||||
type: "string" as const,
|
||||
title: "코드 그룹",
|
||||
description: "source가 code일 때 사용",
|
||||
},
|
||||
searchable: {
|
||||
type: "boolean" as const,
|
||||
default: false,
|
||||
title: "검색 가능",
|
||||
},
|
||||
multiple: {
|
||||
type: "boolean" as const,
|
||||
default: false,
|
||||
title: "다중 선택",
|
||||
},
|
||||
maxSelect: {
|
||||
type: "number" as const,
|
||||
title: "최대 선택 수",
|
||||
},
|
||||
cascading: {
|
||||
type: "object" as const,
|
||||
title: "연쇄 관계",
|
||||
properties: {
|
||||
parentField: { type: "string" as const, title: "부모 필드" },
|
||||
filterColumn: { type: "string" as const, title: "필터 컬럼" },
|
||||
clearOnChange: { type: "boolean" as const, default: true, title: "부모 변경시 초기화" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// UnifiedDate 기본 스키마
|
||||
UnifiedDate: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
type: {
|
||||
type: "string" as const,
|
||||
enum: ["date", "time", "datetime"],
|
||||
default: "date",
|
||||
title: "타입",
|
||||
},
|
||||
format: {
|
||||
type: "string" as const,
|
||||
default: "YYYY-MM-DD",
|
||||
title: "날짜 형식",
|
||||
},
|
||||
range: {
|
||||
type: "boolean" as const,
|
||||
default: false,
|
||||
title: "범위 선택",
|
||||
},
|
||||
showToday: {
|
||||
type: "boolean" as const,
|
||||
default: true,
|
||||
title: "오늘 버튼",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, UnifiedConfigSchema>;
|
||||
|
||||
export default DynamicConfigPanel;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedBiz
|
||||
*
|
||||
* 통합 비즈니스 컴포넌트
|
||||
* - flow: 플로우/워크플로우
|
||||
* - rack: 랙 구조
|
||||
* - map: 맵/위치
|
||||
* - numbering: 채번 규칙
|
||||
* - category: 카테고리 관리
|
||||
* - mapping: 데이터 매핑
|
||||
* - related-buttons: 관련 데이터 버튼
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedBizProps } from "@/types/unified-components";
|
||||
import {
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
Hash,
|
||||
FolderTree,
|
||||
Link2,
|
||||
FileText,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* 플로우 컴포넌트 (플레이스홀더)
|
||||
* 실제 구현은 기존 FlowWidget과 연동
|
||||
*/
|
||||
const FlowBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
플로우
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<GitBranch className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm">플로우 디자이너</p>
|
||||
<p className="text-xs">기존 FlowWidget과 연동</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
FlowBiz.displayName = "FlowBiz";
|
||||
|
||||
/**
|
||||
* 랙 구조 컴포넌트 (플레이스홀더)
|
||||
* 실제 구현은 기존 RackStructure와 연동
|
||||
*/
|
||||
const RackBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
랙 구조
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<LayoutGrid className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm">랙 구조 뷰어</p>
|
||||
<p className="text-xs">기존 RackStructure와 연동</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
RackBiz.displayName = "RackBiz";
|
||||
|
||||
/**
|
||||
* 맵 컴포넌트 (플레이스홀더)
|
||||
*/
|
||||
const MapBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
위치 맵
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 border-2 border-dashed rounded-lg flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-8 w-8 mx-auto mb-2" />
|
||||
<p className="text-sm">위치 맵 뷰어</p>
|
||||
<p className="text-xs">지도 라이브러리 연동 예정</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
MapBiz.displayName = "MapBiz";
|
||||
|
||||
/**
|
||||
* 채번 규칙 컴포넌트 (플레이스홀더)
|
||||
* 실제 구현은 기존 NumberingRuleComponent와 연동
|
||||
*/
|
||||
const NumberingBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Hash className="h-4 w-4" />
|
||||
채번 규칙
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-sm">자동 채번</p>
|
||||
<p className="text-xs text-muted-foreground">규칙에 따라 자동 생성</p>
|
||||
</div>
|
||||
<div className="font-mono text-sm bg-background px-2 py-1 rounded border">
|
||||
PO-2024-0001
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
기존 NumberingRuleComponent와 연동
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
NumberingBiz.displayName = "NumberingBiz";
|
||||
|
||||
/**
|
||||
* 카테고리 관리 컴포넌트 (플레이스홀더)
|
||||
* 실제 구현은 기존 CategoryManager와 연동
|
||||
*/
|
||||
const CategoryBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
카테고리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="pl-0 py-1 px-2 bg-muted/50 rounded">
|
||||
<span className="text-sm">대분류</span>
|
||||
</div>
|
||||
<div className="pl-4 py-1 px-2 text-sm text-muted-foreground">
|
||||
└ 중분류
|
||||
</div>
|
||||
<div className="pl-8 py-1 px-2 text-sm text-muted-foreground">
|
||||
└ 소분류
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center mt-3">
|
||||
기존 CategoryManager와 연동
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CategoryBiz.displayName = "CategoryBiz";
|
||||
|
||||
/**
|
||||
* 데이터 매핑 컴포넌트 (플레이스홀더)
|
||||
*/
|
||||
const MappingBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
데이터 매핑
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 justify-center p-4">
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
|
||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">소스</p>
|
||||
</div>
|
||||
<ArrowRight className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<div className="w-20 h-20 border-2 rounded-lg flex items-center justify-center mb-2">
|
||||
<FileText className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">대상</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
MappingBiz.displayName = "MappingBiz";
|
||||
|
||||
/**
|
||||
* 관련 데이터 버튼 컴포넌트 (플레이스홀더)
|
||||
*/
|
||||
const RelatedButtonsBiz = forwardRef<HTMLDivElement, {
|
||||
config?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}>(({ config, className }, ref) => {
|
||||
const buttons = (config?.buttons as Array<{ label: string; icon?: string }>) || [
|
||||
{ label: "관련 주문" },
|
||||
{ label: "관련 출고" },
|
||||
{ label: "이력 보기" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
|
||||
{buttons.map((button, index) => (
|
||||
<Button key={index} variant="outline" size="sm">
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
RelatedButtonsBiz.displayName = "RelatedButtonsBiz";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedBiz 컴포넌트
|
||||
*/
|
||||
export const UnifiedBiz = forwardRef<HTMLDivElement, UnifiedBizProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { type: "flow" as const };
|
||||
|
||||
// 타입별 비즈니스 컴포넌트 렌더링
|
||||
const renderBiz = () => {
|
||||
const bizConfig = config.config || {};
|
||||
const bizType = config.type || "flow";
|
||||
|
||||
switch (bizType) {
|
||||
case "flow":
|
||||
return <FlowBiz config={bizConfig} />;
|
||||
|
||||
case "rack":
|
||||
return <RackBiz config={bizConfig} />;
|
||||
|
||||
case "map":
|
||||
return <MapBiz config={bizConfig} />;
|
||||
|
||||
case "numbering":
|
||||
return <NumberingBiz config={bizConfig} />;
|
||||
|
||||
case "category":
|
||||
return <CategoryBiz config={bizConfig} />;
|
||||
|
||||
case "mapping":
|
||||
return <MappingBiz config={bizConfig} />;
|
||||
|
||||
case "related-buttons":
|
||||
return <RelatedButtonsBiz config={bizConfig} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4 border rounded text-center text-muted-foreground">
|
||||
알 수 없는 비즈니스 타입: {config.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="text-sm font-medium flex-shrink-0"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderBiz()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedBiz.displayName = "UnifiedBiz";
|
||||
|
||||
export default UnifiedBiz;
|
||||
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedComponentRenderer
|
||||
*
|
||||
* Unified 컴포넌트를 동적으로 렌더링하는 컴포넌트
|
||||
* props.unifiedType에 따라 적절한 컴포넌트를 렌더링
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useMemo } from "react";
|
||||
import {
|
||||
UnifiedComponentProps,
|
||||
isUnifiedInput,
|
||||
isUnifiedSelect,
|
||||
isUnifiedDate,
|
||||
isUnifiedText,
|
||||
isUnifiedMedia,
|
||||
isUnifiedList,
|
||||
isUnifiedLayout,
|
||||
isUnifiedGroup,
|
||||
isUnifiedBiz,
|
||||
isUnifiedHierarchy,
|
||||
} from "@/types/unified-components";
|
||||
import { UnifiedInput } from "./UnifiedInput";
|
||||
import { UnifiedSelect } from "./UnifiedSelect";
|
||||
import { UnifiedDate } from "./UnifiedDate";
|
||||
import { UnifiedList } from "./UnifiedList";
|
||||
import { UnifiedLayout } from "./UnifiedLayout";
|
||||
import { UnifiedGroup } from "./UnifiedGroup";
|
||||
import { UnifiedMedia } from "./UnifiedMedia";
|
||||
import { UnifiedBiz } from "./UnifiedBiz";
|
||||
import { UnifiedHierarchy } from "./UnifiedHierarchy";
|
||||
|
||||
interface UnifiedComponentRendererProps {
|
||||
props: UnifiedComponentProps;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified 컴포넌트 렌더러
|
||||
*/
|
||||
export const UnifiedComponentRenderer = forwardRef<HTMLDivElement, UnifiedComponentRendererProps>(
|
||||
({ props, className }, ref) => {
|
||||
const component = useMemo(() => {
|
||||
// 타입 가드를 사용하여 적절한 컴포넌트 렌더링
|
||||
if (isUnifiedInput(props)) {
|
||||
return <UnifiedInput {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedSelect(props)) {
|
||||
return <UnifiedSelect {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedDate(props)) {
|
||||
return <UnifiedDate {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedText(props)) {
|
||||
// UnifiedText는 UnifiedInput의 textarea 모드로 대체
|
||||
// 필요시 별도 구현
|
||||
return (
|
||||
<div className="p-2 border rounded text-sm text-muted-foreground">
|
||||
UnifiedText (UnifiedInput textarea 모드 사용 권장)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUnifiedMedia(props)) {
|
||||
return <UnifiedMedia {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedList(props)) {
|
||||
return <UnifiedList {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedLayout(props)) {
|
||||
return <UnifiedLayout {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedGroup(props)) {
|
||||
return <UnifiedGroup {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedBiz(props)) {
|
||||
return <UnifiedBiz {...props} />;
|
||||
}
|
||||
|
||||
if (isUnifiedHierarchy(props)) {
|
||||
return <UnifiedHierarchy {...props} />;
|
||||
}
|
||||
|
||||
// 알 수 없는 타입
|
||||
return (
|
||||
<div className="p-2 border border-destructive rounded text-sm text-destructive">
|
||||
알 수 없는 컴포넌트 타입: {(props as { unifiedType?: string }).unifiedType}
|
||||
</div>
|
||||
);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedComponentRenderer.displayName = "UnifiedComponentRenderer";
|
||||
|
||||
export default UnifiedComponentRenderer;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,693 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedFormContext
|
||||
*
|
||||
* Unified 컴포넌트들이 폼 상태를 공유하고
|
||||
* 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context
|
||||
*
|
||||
* 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
|
||||
import { ValidationRule } from "@/types/unified-core";
|
||||
import type {
|
||||
FormStatus,
|
||||
FieldError,
|
||||
FieldState,
|
||||
SubmitConfig,
|
||||
SubmitResult,
|
||||
ValidationResult,
|
||||
FormEventDetail,
|
||||
} from "@/types/unified-form";
|
||||
|
||||
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
|
||||
|
||||
export interface FormFieldState {
|
||||
value: unknown;
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FormState {
|
||||
[fieldId: string]: FormFieldState;
|
||||
}
|
||||
|
||||
// ===== 확장된 Context 타입 =====
|
||||
|
||||
export interface UnifiedFormContextValue {
|
||||
// === 기존 기능 (하위 호환) ===
|
||||
formData: Record<string, unknown>;
|
||||
fieldStates: FormState;
|
||||
|
||||
getValue: (fieldId: string) => unknown;
|
||||
setValue: (fieldId: string, value: unknown) => void;
|
||||
setValues: (values: Record<string, unknown>) => void;
|
||||
|
||||
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
getCascadingFilter: (config?: CascadingConfig) => unknown;
|
||||
|
||||
registerField: (fieldId: string, initialValue?: unknown) => void;
|
||||
unregisterField: (fieldId: string) => void;
|
||||
|
||||
// === 새로운 기능 ===
|
||||
|
||||
// 원본 데이터 (수정 모드)
|
||||
originalData: Record<string, unknown>;
|
||||
|
||||
// 폼 상태
|
||||
status: FormStatus;
|
||||
errors: FieldError[];
|
||||
|
||||
// 폼 액션
|
||||
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
|
||||
reset: () => void;
|
||||
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
|
||||
clear: () => void;
|
||||
|
||||
// 초기 데이터 설정 (수정 모드 진입)
|
||||
setInitialData: (data: Record<string, unknown>) => void;
|
||||
|
||||
// 에러 관리
|
||||
setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void;
|
||||
clearFieldError: (fieldId: string) => void;
|
||||
clearAllErrors: () => void;
|
||||
|
||||
// dirty 체크
|
||||
getChangedFields: () => string[];
|
||||
hasChanges: () => boolean;
|
||||
|
||||
// 리피터 데이터 관리
|
||||
getRepeaterData: (fieldName: string) => unknown[];
|
||||
setRepeaterData: (fieldName: string, data: unknown[]) => void;
|
||||
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
|
||||
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
|
||||
deleteRepeaterRow: (fieldName: string, index: number) => void;
|
||||
}
|
||||
|
||||
// ===== Context 생성 =====
|
||||
|
||||
const UnifiedFormContext = createContext<UnifiedFormContextValue | null>(null);
|
||||
|
||||
// ===== 조건 평가 함수 =====
|
||||
|
||||
function evaluateOperator(
|
||||
fieldValue: unknown,
|
||||
operator: ConditionalConfig["operator"],
|
||||
conditionValue: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return fieldValue === conditionValue;
|
||||
case "!=":
|
||||
return fieldValue !== conditionValue;
|
||||
case ">":
|
||||
return Number(fieldValue) > Number(conditionValue);
|
||||
case "<":
|
||||
return Number(fieldValue) < Number(conditionValue);
|
||||
case "in":
|
||||
if (Array.isArray(conditionValue)) {
|
||||
return conditionValue.includes(fieldValue);
|
||||
}
|
||||
return false;
|
||||
case "notIn":
|
||||
if (Array.isArray(conditionValue)) {
|
||||
return !conditionValue.includes(fieldValue);
|
||||
}
|
||||
return true;
|
||||
case "isEmpty":
|
||||
return fieldValue === null || fieldValue === undefined || fieldValue === "" ||
|
||||
(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
case "isNotEmpty":
|
||||
return fieldValue !== null && fieldValue !== undefined && fieldValue !== "" &&
|
||||
!(Array.isArray(fieldValue) && fieldValue.length === 0);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 초기 상태 =====
|
||||
|
||||
const initialFormStatus: FormStatus = {
|
||||
isSubmitting: false,
|
||||
isValidating: false,
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
isLoading: false,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
// ===== Provider Props =====
|
||||
|
||||
interface UnifiedFormProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialValues?: Record<string, unknown>;
|
||||
onChange?: (formData: Record<string, unknown>) => void;
|
||||
|
||||
// 새로운 Props
|
||||
submitConfig?: SubmitConfig;
|
||||
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
|
||||
onError?: (errors: FieldError[]) => void;
|
||||
onReset?: () => void;
|
||||
|
||||
// 레거시 호환성
|
||||
emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true)
|
||||
}
|
||||
|
||||
// ===== Provider 컴포넌트 =====
|
||||
|
||||
export function UnifiedFormProvider({
|
||||
children,
|
||||
initialValues = {},
|
||||
onChange,
|
||||
submitConfig: defaultSubmitConfig,
|
||||
onSubmit,
|
||||
onError,
|
||||
onReset,
|
||||
emitLegacyEvents = true,
|
||||
}: UnifiedFormProviderProps) {
|
||||
// 기존 상태
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
||||
const [fieldStates, setFieldStates] = useState<FormState>({});
|
||||
|
||||
// 새로운 상태
|
||||
const [originalData, setOriginalData] = useState<Record<string, unknown>>(initialValues);
|
||||
const [status, setStatus] = useState<FormStatus>(initialFormStatus);
|
||||
const [errors, setErrors] = useState<FieldError[]>([]);
|
||||
|
||||
// 필드별 검증 규칙 저장
|
||||
const validationRulesRef = useRef<Map<string, ValidationRule[]>>(new Map());
|
||||
|
||||
// ===== 기존 기능 =====
|
||||
|
||||
const getValue = useCallback((fieldId: string): unknown => {
|
||||
return formData[fieldId];
|
||||
}, [formData]);
|
||||
|
||||
const setValue = useCallback((fieldId: string, value: unknown) => {
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, [fieldId]: value };
|
||||
|
||||
// dirty 상태 업데이트
|
||||
setStatus(s => ({ ...s, isDirty: true }));
|
||||
|
||||
onChange?.(newData);
|
||||
return newData;
|
||||
});
|
||||
}, [onChange]);
|
||||
|
||||
const setValues = useCallback((values: Record<string, unknown>) => {
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, ...values };
|
||||
setStatus(s => ({ ...s, isDirty: true }));
|
||||
onChange?.(newData);
|
||||
return newData;
|
||||
});
|
||||
}, [onChange]);
|
||||
|
||||
const evaluateCondition = useCallback((
|
||||
fieldId: string,
|
||||
config?: ConditionalConfig
|
||||
): { visible: boolean; disabled: boolean } => {
|
||||
if (!config || !config.enabled) {
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
|
||||
const { field, operator, value, action } = config;
|
||||
const fieldValue = formData[field];
|
||||
const conditionMet = evaluateOperator(fieldValue, operator, value);
|
||||
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { visible: conditionMet, disabled: false };
|
||||
case "hide":
|
||||
return { visible: !conditionMet, disabled: false };
|
||||
case "enable":
|
||||
return { visible: true, disabled: !conditionMet };
|
||||
case "disable":
|
||||
return { visible: true, disabled: conditionMet };
|
||||
default:
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
}, [formData]);
|
||||
|
||||
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
|
||||
if (!config) return undefined;
|
||||
return formData[config.parentField];
|
||||
}, [formData]);
|
||||
|
||||
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
|
||||
if (initialValue !== undefined && formData[fieldId] === undefined) {
|
||||
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
|
||||
}
|
||||
setFieldStates(prev => ({
|
||||
...prev,
|
||||
[fieldId]: { value: initialValue, visible: true, disabled: false },
|
||||
}));
|
||||
}, [formData]);
|
||||
|
||||
const unregisterField = useCallback((fieldId: string) => {
|
||||
setFieldStates(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fieldId];
|
||||
return next;
|
||||
});
|
||||
validationRulesRef.current.delete(fieldId);
|
||||
}, []);
|
||||
|
||||
// ===== 새로운 기능: 폼 액션 =====
|
||||
|
||||
// 검증
|
||||
const validate = useCallback(async (fieldIds?: string[]): Promise<ValidationResult> => {
|
||||
setStatus(s => ({ ...s, isValidating: true }));
|
||||
|
||||
const newErrors: FieldError[] = [];
|
||||
const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys());
|
||||
|
||||
for (const fieldId of fieldsToValidate) {
|
||||
const rules = validationRulesRef.current.get(fieldId);
|
||||
if (!rules) continue;
|
||||
|
||||
const value = formData[fieldId];
|
||||
|
||||
for (const rule of rules) {
|
||||
let isValid = true;
|
||||
|
||||
switch (rule.type) {
|
||||
case "required":
|
||||
isValid = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
case "minLength":
|
||||
isValid = typeof value === "string" && value.length >= (rule.value as number);
|
||||
break;
|
||||
case "maxLength":
|
||||
isValid = typeof value === "string" && value.length <= (rule.value as number);
|
||||
break;
|
||||
case "min":
|
||||
isValid = typeof value === "number" && value >= (rule.value as number);
|
||||
break;
|
||||
case "max":
|
||||
isValid = typeof value === "number" && value <= (rule.value as number);
|
||||
break;
|
||||
case "pattern":
|
||||
isValid = typeof value === "string" && new RegExp(rule.value as string).test(value);
|
||||
break;
|
||||
case "email":
|
||||
isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
break;
|
||||
case "url":
|
||||
isValid = typeof value === "string" && /^https?:\/\/.+/.test(value);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
newErrors.push({
|
||||
fieldId,
|
||||
message: rule.message,
|
||||
type: rule.type === "required" ? "required" : "format",
|
||||
});
|
||||
break; // 첫 번째 에러만 기록
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setStatus(s => ({
|
||||
...s,
|
||||
isValidating: false,
|
||||
isValid: newErrors.length === 0
|
||||
}));
|
||||
|
||||
return { valid: newErrors.length === 0, errors: newErrors };
|
||||
}, [formData]);
|
||||
|
||||
// 저장
|
||||
const submit = useCallback(async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
|
||||
const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig;
|
||||
|
||||
setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 }));
|
||||
|
||||
try {
|
||||
// 1. 검증
|
||||
if (finalConfig.validateBeforeSubmit !== false) {
|
||||
const validation = await validate();
|
||||
if (!validation.valid) {
|
||||
onError?.(validation.errors);
|
||||
return { success: false, error: "검증 실패", errors: validation.errors };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 레거시 이벤트 발생 (리피터 데이터 수집)
|
||||
let collectedData = { ...formData };
|
||||
|
||||
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||
const eventDetail: FormEventDetail = { formData: {} };
|
||||
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
|
||||
window.dispatchEvent(legacyEvent);
|
||||
|
||||
// 이벤트에서 수집된 데이터 병합 (리피터 등)
|
||||
collectedData = { ...collectedData, ...eventDetail.formData };
|
||||
}
|
||||
|
||||
// 3. beforeSubmit 콜백
|
||||
if (finalConfig.onBeforeSubmit) {
|
||||
collectedData = await finalConfig.onBeforeSubmit(collectedData);
|
||||
}
|
||||
|
||||
// 4. 추가 데이터 병합
|
||||
if (finalConfig.additionalData) {
|
||||
collectedData = { ...collectedData, ...finalConfig.additionalData };
|
||||
}
|
||||
|
||||
// 5. 저장 실행
|
||||
let result: SubmitResult;
|
||||
|
||||
if (onSubmit) {
|
||||
result = await onSubmit(collectedData, finalConfig);
|
||||
} else {
|
||||
// 기본 저장 로직 (API 호출)
|
||||
// 실제 구현은 외부에서 onSubmit으로 제공
|
||||
result = { success: true, data: collectedData };
|
||||
}
|
||||
|
||||
// 6. 성공 시 처리
|
||||
if (result.success) {
|
||||
setOriginalData({ ...formData });
|
||||
setStatus(s => ({ ...s, isDirty: false }));
|
||||
|
||||
// afterFormSave 이벤트 발생
|
||||
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("afterFormSave", {
|
||||
detail: { success: true, data: result.data }
|
||||
}));
|
||||
}
|
||||
|
||||
// afterSubmit 콜백
|
||||
finalConfig.onAfterSubmit?.(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생";
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setStatus(s => ({ ...s, isSubmitting: false }));
|
||||
}
|
||||
}, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]);
|
||||
|
||||
// 초기화 (원본 데이터로 복원)
|
||||
const reset = useCallback(() => {
|
||||
setFormData({ ...originalData });
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isDirty: false, isValid: true }));
|
||||
onReset?.();
|
||||
}, [originalData, onReset]);
|
||||
|
||||
// 비우기
|
||||
const clear = useCallback(() => {
|
||||
setFormData({});
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isDirty: true, isValid: true }));
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 설정 (수정 모드 진입)
|
||||
const setInitialData = useCallback((data: Record<string, unknown>) => {
|
||||
setFormData(data);
|
||||
setOriginalData(data);
|
||||
setStatus(s => ({ ...s, isDirty: false }));
|
||||
}, []);
|
||||
|
||||
// ===== 에러 관리 =====
|
||||
|
||||
const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => {
|
||||
setErrors(prev => {
|
||||
const filtered = prev.filter(e => e.fieldId !== fieldId);
|
||||
return [...filtered, { fieldId, message, type }];
|
||||
});
|
||||
setStatus(s => ({ ...s, isValid: false }));
|
||||
}, []);
|
||||
|
||||
const clearFieldError = useCallback((fieldId: string) => {
|
||||
setErrors(prev => {
|
||||
const filtered = prev.filter(e => e.fieldId !== fieldId);
|
||||
return filtered;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isValid: true }));
|
||||
}, []);
|
||||
|
||||
// ===== dirty 체크 =====
|
||||
|
||||
const getChangedFields = useCallback((): string[] => {
|
||||
const changed: string[] = [];
|
||||
const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}, [formData, originalData]);
|
||||
|
||||
const hasChanges = useCallback((): boolean => {
|
||||
return JSON.stringify(formData) !== JSON.stringify(originalData);
|
||||
}, [formData, originalData]);
|
||||
|
||||
// ===== 리피터 데이터 관리 =====
|
||||
|
||||
const getRepeaterData = useCallback((fieldName: string): unknown[] => {
|
||||
const data = formData[fieldName];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [formData]);
|
||||
|
||||
const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => {
|
||||
setValue(fieldName, data);
|
||||
}, [setValue]);
|
||||
|
||||
const addRepeaterRow = useCallback((fieldName: string, row: Record<string, unknown>) => {
|
||||
const current = getRepeaterData(fieldName);
|
||||
setValue(fieldName, [...current, row]);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record<string, unknown>) => {
|
||||
const current = getRepeaterData(fieldName) as Record<string, unknown>[];
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], ...row };
|
||||
setValue(fieldName, updated);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
const deleteRepeaterRow = useCallback((fieldName: string, index: number) => {
|
||||
const current = getRepeaterData(fieldName);
|
||||
const updated = current.filter((_, i) => i !== index);
|
||||
setValue(fieldName, updated);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
// ===== Context 값 =====
|
||||
|
||||
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
|
||||
// 기존 기능
|
||||
formData,
|
||||
fieldStates,
|
||||
getValue,
|
||||
setValue,
|
||||
setValues,
|
||||
evaluateCondition,
|
||||
getCascadingFilter,
|
||||
registerField,
|
||||
unregisterField,
|
||||
|
||||
// 새로운 기능
|
||||
originalData,
|
||||
status,
|
||||
errors,
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
setInitialData,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
clearAllErrors,
|
||||
getChangedFields,
|
||||
hasChanges,
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow,
|
||||
}), [
|
||||
formData,
|
||||
fieldStates,
|
||||
getValue,
|
||||
setValue,
|
||||
setValues,
|
||||
evaluateCondition,
|
||||
getCascadingFilter,
|
||||
registerField,
|
||||
unregisterField,
|
||||
originalData,
|
||||
status,
|
||||
errors,
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
setInitialData,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
clearAllErrors,
|
||||
getChangedFields,
|
||||
hasChanges,
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow,
|
||||
]);
|
||||
|
||||
return (
|
||||
<UnifiedFormContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</UnifiedFormContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 커스텀 훅 =====
|
||||
|
||||
/**
|
||||
* UnifiedForm 컨텍스트 사용 (Context가 없으면 에러)
|
||||
*/
|
||||
export function useUnifiedForm(): UnifiedFormContextValue {
|
||||
const context = useContext(UnifiedFormContext);
|
||||
if (!context) {
|
||||
throw new Error("useUnifiedForm must be used within UnifiedFormProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환)
|
||||
* 레거시 호환성을 위해 사용
|
||||
*/
|
||||
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
|
||||
return useContext(UnifiedFormContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
||||
*/
|
||||
export function useUnifiedField(
|
||||
fieldId: string,
|
||||
conditional?: ConditionalConfig
|
||||
): {
|
||||
value: unknown;
|
||||
setValue: (value: unknown) => void;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
error?: FieldError;
|
||||
} {
|
||||
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
|
||||
|
||||
const value = getValue(fieldId);
|
||||
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
||||
const error = errors.find(e => e.fieldId === fieldId);
|
||||
|
||||
const handleSetValue = useCallback((newValue: unknown) => {
|
||||
setValue(fieldId, newValue);
|
||||
}, [fieldId, setValue]);
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue: handleSetValue,
|
||||
visible,
|
||||
disabled,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 연쇄 선택 훅 - 부모 필드 값에 따라 옵션 필터링
|
||||
*/
|
||||
export function useCascadingOptions<T extends { parentValue?: unknown }>(
|
||||
options: T[],
|
||||
cascading?: CascadingConfig
|
||||
): T[] {
|
||||
const { getCascadingFilter } = useUnifiedForm();
|
||||
|
||||
if (!cascading) return options;
|
||||
|
||||
const parentValue = getCascadingFilter(cascading);
|
||||
|
||||
if (parentValue === undefined || parentValue === null || parentValue === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return options.filter(opt => opt.parentValue === parentValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 액션 훅 - 저장/검증/초기화 등 액션에만 접근
|
||||
*/
|
||||
export function useFormActions() {
|
||||
const { submit, reset, validate, clear, hasChanges, status, errors } = useUnifiedForm();
|
||||
|
||||
return {
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
hasChanges,
|
||||
isSubmitting: status.isSubmitting,
|
||||
isValidating: status.isValidating,
|
||||
isDirty: status.isDirty,
|
||||
isValid: status.isValid,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 데이터 훅 - 특정 리피터 필드 관리
|
||||
*/
|
||||
export function useRepeaterField<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
fieldName: string
|
||||
) {
|
||||
const {
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow
|
||||
} = useUnifiedForm();
|
||||
|
||||
const data = getRepeaterData(fieldName) as T[];
|
||||
|
||||
return {
|
||||
data,
|
||||
setData: (newData: T[]) => setRepeaterData(fieldName, newData),
|
||||
addRow: (row: T) => addRepeaterRow(fieldName, row),
|
||||
updateRow: (index: number, row: Partial<T>) => updateRepeaterRow(fieldName, index, row as Record<string, unknown>),
|
||||
deleteRow: (index: number) => deleteRepeaterRow(fieldName, index),
|
||||
count: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default UnifiedFormContext;
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedGroup
|
||||
*
|
||||
* 통합 그룹 컴포넌트
|
||||
* - tabs: 탭 그룹
|
||||
* - accordion: 아코디언 그룹
|
||||
* - section: 섹션 그룹
|
||||
* - card-section: 카드 섹션
|
||||
* - modal: 모달 그룹
|
||||
* - form-modal: 폼 모달 그룹
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useState, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedGroupProps, TabItem } from "@/types/unified-components";
|
||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 탭 그룹 컴포넌트
|
||||
*/
|
||||
const TabsGroup = forwardRef<HTMLDivElement, {
|
||||
tabs?: TabItem[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (tabId: string) => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ tabs = [], activeTab, onTabChange, children, className }, ref) => {
|
||||
const [internalActiveTab, setInternalActiveTab] = useState(activeTab || tabs[0]?.id || "");
|
||||
|
||||
const currentTab = activeTab || internalActiveTab;
|
||||
|
||||
const handleTabChange = useCallback((tabId: string) => {
|
||||
setInternalActiveTab(tabId);
|
||||
onTabChange?.(tabId);
|
||||
}, [onTabChange]);
|
||||
|
||||
// 탭 정보가 있으면 탭 사용, 없으면 children 그대로 렌더링
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
ref={ref}
|
||||
value={currentTab}
|
||||
onValueChange={handleTabChange}
|
||||
className={className}
|
||||
>
|
||||
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${tabs.length}, 1fr)` }}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
||||
{tab.content || children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
});
|
||||
TabsGroup.displayName = "TabsGroup";
|
||||
|
||||
/**
|
||||
* 아코디언 그룹 컴포넌트
|
||||
*/
|
||||
const AccordionGroup = forwardRef<HTMLDivElement, {
|
||||
title?: string;
|
||||
collapsible?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ title, collapsible = true, defaultExpanded = true, children, className }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||
|
||||
if (!collapsible) {
|
||||
return (
|
||||
<div ref={ref} className={cn("border rounded-lg", className)}>
|
||||
{title && (
|
||||
<div className="p-4 border-b bg-muted/50">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("border rounded-lg", className)}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50">
|
||||
<h3 className="font-medium">{title || "그룹"}</h3>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-4 pt-0 border-t">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
});
|
||||
AccordionGroup.displayName = "AccordionGroup";
|
||||
|
||||
/**
|
||||
* 섹션 그룹 컴포넌트
|
||||
*/
|
||||
const SectionGroup = forwardRef<HTMLDivElement, {
|
||||
title?: string;
|
||||
description?: string;
|
||||
collapsible?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<Collapsible ref={ref} open={isOpen} onOpenChange={setIsOpen} className={cn("space-y-2", className)}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer">
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("space-y-2", className)}>
|
||||
{(title || description) && (
|
||||
<div>
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SectionGroup.displayName = "SectionGroup";
|
||||
|
||||
/**
|
||||
* 카드 섹션 그룹 컴포넌트
|
||||
*/
|
||||
const CardSectionGroup = forwardRef<HTMLDivElement, {
|
||||
title?: string;
|
||||
description?: string;
|
||||
collapsible?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ title, description, collapsible = false, defaultExpanded = true, children, className }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultExpanded);
|
||||
|
||||
if (collapsible) {
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">{children}</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card ref={ref} className={className}>
|
||||
{(title || description) && (
|
||||
<CardHeader>
|
||||
{title && <CardTitle>{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={title || description ? "pt-0" : ""}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CardSectionGroup.displayName = "CardSectionGroup";
|
||||
|
||||
/**
|
||||
* 모달 그룹 컴포넌트
|
||||
*/
|
||||
const ModalGroup = forwardRef<HTMLDivElement, {
|
||||
title?: string;
|
||||
description?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ title, description, open = false, onOpenChange, modalSize = "md", children, className }, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||
{(title || description) && (
|
||||
<DialogHeader>
|
||||
{title && <DialogTitle>{title}</DialogTitle>}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
ModalGroup.displayName = "ModalGroup";
|
||||
|
||||
/**
|
||||
* 폼 모달 그룹 컴포넌트
|
||||
*/
|
||||
const FormModalGroup = forwardRef<HTMLDivElement, {
|
||||
title?: string;
|
||||
description?: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||
onSubmit?: () => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({
|
||||
title,
|
||||
description,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
modalSize = "md",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = "저장",
|
||||
cancelLabel = "취소",
|
||||
children,
|
||||
className
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onCancel?.();
|
||||
onOpenChange?.(false);
|
||||
}, [onCancel, onOpenChange]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onSubmit?.();
|
||||
}, [onSubmit]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent ref={ref} className={cn(sizeClasses[modalSize], className)}>
|
||||
{(title || description) && (
|
||||
<DialogHeader>
|
||||
{title && <DialogTitle>{title}</DialogTitle>}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div className="py-4">{children}</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
FormModalGroup.displayName = "FormModalGroup";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedGroup 컴포넌트
|
||||
*/
|
||||
export const UnifiedGroup = forwardRef<HTMLDivElement, UnifiedGroupProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { type: "section" as const, tabs: [] };
|
||||
|
||||
// 타입별 그룹 렌더링
|
||||
const renderGroup = () => {
|
||||
const groupType = config.type || "section";
|
||||
switch (groupType) {
|
||||
case "tabs":
|
||||
return (
|
||||
<TabsGroup
|
||||
tabs={config.tabs}
|
||||
activeTab={config.activeTab}
|
||||
>
|
||||
{children}
|
||||
</TabsGroup>
|
||||
);
|
||||
|
||||
case "accordion":
|
||||
return (
|
||||
<AccordionGroup
|
||||
title={config.title}
|
||||
collapsible={config.collapsible}
|
||||
defaultExpanded={config.defaultExpanded}
|
||||
>
|
||||
{children}
|
||||
</AccordionGroup>
|
||||
);
|
||||
|
||||
case "section":
|
||||
return (
|
||||
<SectionGroup
|
||||
title={config.title}
|
||||
collapsible={config.collapsible}
|
||||
defaultExpanded={config.defaultExpanded}
|
||||
>
|
||||
{children}
|
||||
</SectionGroup>
|
||||
);
|
||||
|
||||
case "card-section":
|
||||
return (
|
||||
<CardSectionGroup
|
||||
title={config.title}
|
||||
collapsible={config.collapsible}
|
||||
defaultExpanded={config.defaultExpanded}
|
||||
>
|
||||
{children}
|
||||
</CardSectionGroup>
|
||||
);
|
||||
|
||||
case "modal":
|
||||
return (
|
||||
<ModalGroup
|
||||
title={config.title}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
modalSize={config.modalSize}
|
||||
>
|
||||
{children}
|
||||
</ModalGroup>
|
||||
);
|
||||
|
||||
case "form-modal":
|
||||
return (
|
||||
<FormModalGroup
|
||||
title={config.title}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
modalSize={config.modalSize}
|
||||
>
|
||||
{children}
|
||||
</FormModalGroup>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<SectionGroup title={config.title}>
|
||||
{children}
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{renderGroup()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedGroup.displayName = "UnifiedGroup";
|
||||
|
||||
export default UnifiedGroup;
|
||||
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedHierarchy
|
||||
*
|
||||
* 통합 계층 구조 컴포넌트
|
||||
* - tree: 트리 뷰
|
||||
* - org: 조직도
|
||||
* - bom: BOM 구조
|
||||
* - cascading: 연쇄 드롭다운
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedHierarchyProps, HierarchyNode } from "@/types/unified-components";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
Plus,
|
||||
Minus,
|
||||
GripVertical,
|
||||
User,
|
||||
Users,
|
||||
Building
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* 트리 노드 컴포넌트
|
||||
*/
|
||||
const TreeNode = forwardRef<HTMLDivElement, {
|
||||
node: HierarchyNode;
|
||||
level: number;
|
||||
maxLevel?: number;
|
||||
selectedNode?: HierarchyNode;
|
||||
onSelect?: (node: HierarchyNode) => void;
|
||||
editable?: boolean;
|
||||
draggable?: boolean;
|
||||
showQty?: boolean;
|
||||
className?: string;
|
||||
}>(({
|
||||
node,
|
||||
level,
|
||||
maxLevel,
|
||||
selectedNode,
|
||||
onSelect,
|
||||
editable,
|
||||
draggable,
|
||||
showQty,
|
||||
className
|
||||
}, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(level < 2);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
|
||||
// 최대 레벨 제한
|
||||
if (maxLevel && level >= maxLevel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
|
||||
isSelected && "bg-primary/10 text-primary"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={() => onSelect?.(node)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
{draggable && (
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||
)}
|
||||
|
||||
{/* 확장/축소 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 p-0">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<span className="w-5" />
|
||||
)}
|
||||
|
||||
{/* 폴더/파일 아이콘 */}
|
||||
{hasChildren ? (
|
||||
isOpen ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-amber-500" />
|
||||
)
|
||||
) : (
|
||||
<File className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* 라벨 */}
|
||||
<span className="flex-1 text-sm truncate">{node.label}</span>
|
||||
|
||||
{/* 수량 (BOM용) */}
|
||||
{showQty && node.data?.qty && (
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
x{String(node.data.qty)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 편집 버튼 */}
|
||||
{editable && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 */}
|
||||
{hasChildren && (
|
||||
<CollapsibleContent>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
maxLevel={maxLevel}
|
||||
selectedNode={selectedNode}
|
||||
onSelect={onSelect}
|
||||
editable={editable}
|
||||
draggable={draggable}
|
||||
showQty={showQty}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeNode.displayName = "TreeNode";
|
||||
|
||||
/**
|
||||
* 트리 뷰 컴포넌트
|
||||
*/
|
||||
const TreeView = forwardRef<HTMLDivElement, {
|
||||
data: HierarchyNode[];
|
||||
selectedNode?: HierarchyNode;
|
||||
onNodeSelect?: (node: HierarchyNode) => void;
|
||||
editable?: boolean;
|
||||
draggable?: boolean;
|
||||
maxLevel?: number;
|
||||
className?: string;
|
||||
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
|
||||
{data.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
데이터가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
data.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
maxLevel={maxLevel}
|
||||
selectedNode={selectedNode}
|
||||
onSelect={onNodeSelect}
|
||||
editable={editable}
|
||||
draggable={draggable}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TreeView.displayName = "TreeView";
|
||||
|
||||
/**
|
||||
* 조직도 뷰 컴포넌트
|
||||
*/
|
||||
const OrgView = forwardRef<HTMLDivElement, {
|
||||
data: HierarchyNode[];
|
||||
selectedNode?: HierarchyNode;
|
||||
onNodeSelect?: (node: HierarchyNode) => void;
|
||||
className?: string;
|
||||
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
|
||||
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="flex flex-col items-center">
|
||||
{/* 노드 카드 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
|
||||
isSelected && "border-primary bg-primary/5",
|
||||
isRoot && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onNodeSelect?.(node)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
|
||||
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}>
|
||||
{isRoot ? (
|
||||
<Building className="h-5 w-5" />
|
||||
) : hasChildren ? (
|
||||
<Users className="h-5 w-5" />
|
||||
) : (
|
||||
<User className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{node.label}</div>
|
||||
{node.data?.title && (
|
||||
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 */}
|
||||
{hasChildren && (
|
||||
<>
|
||||
{/* 연결선 */}
|
||||
<div className="w-px h-4 bg-border" />
|
||||
<div className="flex gap-4">
|
||||
{node.children!.map((child, index) => (
|
||||
<React.Fragment key={child.id}>
|
||||
{index > 0 && <div className="w-4" />}
|
||||
{renderOrgNode(child)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("overflow-auto p-4", className)}>
|
||||
{data.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
조직 데이터가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{data.map((node) => renderOrgNode(node, true))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
OrgView.displayName = "OrgView";
|
||||
|
||||
/**
|
||||
* BOM 뷰 컴포넌트 (수량 포함 트리)
|
||||
*/
|
||||
const BomView = forwardRef<HTMLDivElement, {
|
||||
data: HierarchyNode[];
|
||||
selectedNode?: HierarchyNode;
|
||||
onNodeSelect?: (node: HierarchyNode) => void;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn("border rounded-lg p-2", className)}>
|
||||
{data.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
BOM 데이터가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
data.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedNode={selectedNode}
|
||||
onSelect={onNodeSelect}
|
||||
editable={editable}
|
||||
showQty={true}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BomView.displayName = "BomView";
|
||||
|
||||
/**
|
||||
* 연쇄 드롭다운 컴포넌트
|
||||
*/
|
||||
const CascadingView = forwardRef<HTMLDivElement, {
|
||||
data: HierarchyNode[];
|
||||
selectedNode?: HierarchyNode;
|
||||
onNodeSelect?: (node: HierarchyNode) => void;
|
||||
maxLevel?: number;
|
||||
className?: string;
|
||||
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
|
||||
const [selections, setSelections] = useState<string[]>([]);
|
||||
|
||||
// 레벨별 옵션 가져오기
|
||||
const getOptionsForLevel = (level: number): HierarchyNode[] => {
|
||||
if (level === 0) return data;
|
||||
|
||||
let currentNodes = data;
|
||||
for (let i = 0; i < level; i++) {
|
||||
const selectedId = selections[i];
|
||||
if (!selectedId) return [];
|
||||
|
||||
const selectedNode = currentNodes.find((n) => n.id === selectedId);
|
||||
if (!selectedNode?.children) return [];
|
||||
|
||||
currentNodes = selectedNode.children;
|
||||
}
|
||||
return currentNodes;
|
||||
};
|
||||
|
||||
// 선택 핸들러
|
||||
const handleSelect = (level: number, nodeId: string) => {
|
||||
const newSelections = [...selections.slice(0, level), nodeId];
|
||||
setSelections(newSelections);
|
||||
|
||||
// 마지막 선택된 노드 찾기
|
||||
let node = data.find((n) => n.id === newSelections[0]);
|
||||
for (let i = 1; i < newSelections.length; i++) {
|
||||
node = node?.children?.find((n) => n.id === newSelections[i]);
|
||||
}
|
||||
if (node) {
|
||||
onNodeSelect?.(node);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex gap-2", className)}>
|
||||
{Array.from({ length: maxLevel }, (_, level) => {
|
||||
const options = getOptionsForLevel(level);
|
||||
const isDisabled = level > 0 && !selections[level - 1];
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={level}
|
||||
value={selections[level] || ""}
|
||||
onValueChange={(value) => handleSelect(level, value)}
|
||||
disabled={isDisabled || options.length === 0}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder={`${level + 1}단계 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CascadingView.displayName = "CascadingView";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedHierarchy 컴포넌트
|
||||
*/
|
||||
export const UnifiedHierarchy = forwardRef<HTMLDivElement, UnifiedHierarchyProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
data = [],
|
||||
selectedNode,
|
||||
onNodeSelect,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
|
||||
|
||||
// 뷰모드별 렌더링
|
||||
const renderHierarchy = () => {
|
||||
const viewMode = config.viewMode || config.type || "tree";
|
||||
switch (viewMode) {
|
||||
case "tree":
|
||||
return (
|
||||
<TreeView
|
||||
data={data}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={onNodeSelect}
|
||||
editable={config.editable}
|
||||
draggable={config.draggable}
|
||||
maxLevel={config.maxLevel}
|
||||
/>
|
||||
);
|
||||
|
||||
case "org":
|
||||
return (
|
||||
<OrgView
|
||||
data={data}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
case "bom":
|
||||
return (
|
||||
<BomView
|
||||
data={data}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={onNodeSelect}
|
||||
editable={config.editable}
|
||||
/>
|
||||
);
|
||||
|
||||
case "dropdown":
|
||||
case "cascading":
|
||||
return (
|
||||
<CascadingView
|
||||
data={data}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={onNodeSelect}
|
||||
maxLevel={config.maxLevel}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<TreeView
|
||||
data={data}
|
||||
selectedNode={selectedNode}
|
||||
onNodeSelect={onNodeSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="text-sm font-medium flex-shrink-0"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderHierarchy()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedHierarchy.displayName = "UnifiedHierarchy";
|
||||
|
||||
export default UnifiedHierarchy;
|
||||
|
||||
|
|
@ -0,0 +1,816 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedInput
|
||||
*
|
||||
* 통합 입력 컴포넌트
|
||||
* - text: 텍스트 입력
|
||||
* - number: 숫자 입력
|
||||
* - password: 비밀번호 입력
|
||||
* - slider: 슬라이더 입력
|
||||
* - color: 색상 선택
|
||||
* - button: 버튼 (입력이 아닌 액션)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedInputProps, UnifiedInputConfig, UnifiedInputFormat } from "@/types/unified-components";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
import { previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
// 형식별 입력 마스크 및 검증 패턴
|
||||
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
|
||||
none: { pattern: /.*/, placeholder: "" },
|
||||
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
|
||||
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
|
||||
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
|
||||
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
|
||||
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
|
||||
};
|
||||
|
||||
// 통화 형식 변환
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
||||
if (isNaN(num)) return "";
|
||||
return num.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
// 사업자번호 형식 변환
|
||||
function formatBizNo(value: string): string {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
|
||||
}
|
||||
|
||||
// 전화번호 형식 변환
|
||||
function formatTel(value: string): string {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 입력 컴포넌트
|
||||
*/
|
||||
const TextInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string | number;
|
||||
onChange?: (value: string) => void;
|
||||
format?: UnifiedInputFormat;
|
||||
mask?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
||||
// 형식에 따른 값 포맷팅
|
||||
const formatValue = useCallback(
|
||||
(val: string): string => {
|
||||
switch (format) {
|
||||
case "currency":
|
||||
return formatCurrency(val);
|
||||
case "biz_no":
|
||||
return formatBizNo(val);
|
||||
case "tel":
|
||||
return formatTel(val);
|
||||
default:
|
||||
return val;
|
||||
}
|
||||
},
|
||||
[format],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = e.target.value;
|
||||
|
||||
// 형식에 따른 자동 포맷팅
|
||||
if (format === "currency") {
|
||||
// 숫자와 쉼표만 허용
|
||||
newValue = newValue.replace(/[^\d,]/g, "");
|
||||
newValue = formatCurrency(newValue);
|
||||
} else if (format === "biz_no") {
|
||||
newValue = formatBizNo(newValue);
|
||||
} else if (format === "tel") {
|
||||
newValue = formatTel(newValue);
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[format, onChange],
|
||||
);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (value === undefined || value === null) return "";
|
||||
return formatValue(String(value));
|
||||
}, [value, formatValue]);
|
||||
|
||||
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
placeholder={inputPlaceholder}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TextInput.displayName = "TextInput";
|
||||
|
||||
/**
|
||||
* 숫자 입력 컴포넌트
|
||||
*/
|
||||
const NumberInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: number;
|
||||
onChange?: (value: number | undefined) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let num = parseFloat(val);
|
||||
|
||||
// 범위 제한
|
||||
if (min !== undefined && num < min) num = min;
|
||||
if (max !== undefined && num > max) num = max;
|
||||
|
||||
onChange?.(num);
|
||||
},
|
||||
[min, max, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
placeholder={placeholder || "숫자 입력"}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
NumberInput.displayName = "NumberInput";
|
||||
|
||||
/**
|
||||
* 비밀번호 입력 컴포넌트
|
||||
*/
|
||||
const PasswordInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder || "비밀번호 입력"}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full pr-10", className)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 text-xs"
|
||||
>
|
||||
{showPassword ? "숨김" : "보기"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
/**
|
||||
* 슬라이더 입력 컴포넌트
|
||||
*/
|
||||
const SliderInput = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={cn("flex items-center gap-4", className)}>
|
||||
<Slider
|
||||
value={[value ?? min]}
|
||||
onValueChange={(values) => onChange?.(values[0])}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-12 text-right text-sm font-medium">{value ?? min}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SliderInput.displayName = "SliderInput";
|
||||
|
||||
/**
|
||||
* 색상 선택 컴포넌트
|
||||
*/
|
||||
const ColorInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value, onChange, disabled, className }, ref) => {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Input
|
||||
ref={ref}
|
||||
type="color"
|
||||
value={value || "#000000"}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-full w-12 cursor-pointer p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value || "#000000"}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="h-full flex-1 uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ColorInput.displayName = "ColorInput";
|
||||
|
||||
/**
|
||||
* 여러 줄 텍스트 입력 컴포넌트
|
||||
*/
|
||||
const TextareaInput = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
{
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TextareaInput.displayName = "TextareaInput";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedInput 컴포넌트
|
||||
*/
|
||||
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props, ref) => {
|
||||
const { id, label, required, readonly, disabled, style, size, config: configProp, value, onChange } = props;
|
||||
|
||||
// formData 추출 (채번규칙 날짜 컬럼 기준 생성 시 사용)
|
||||
const formData = (props as any).formData || {};
|
||||
const columnName = (props as any).columnName;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = (configProp || { type: "text" }) as UnifiedInputConfig & {
|
||||
inputType?: string;
|
||||
rows?: number;
|
||||
autoGeneration?: AutoGenerationConfig;
|
||||
};
|
||||
|
||||
// 자동생성 설정 추출
|
||||
const autoGeneration: AutoGenerationConfig = (props as any).autoGeneration ||
|
||||
(config as any).autoGeneration || {
|
||||
type: "none",
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
// 자동생성 상태 관리
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string | null>(null);
|
||||
const isGeneratingRef = useRef(false);
|
||||
const hasGeneratedRef = useRef(false);
|
||||
const lastFormDataRef = useRef<string>(""); // 마지막 formData 추적 (채번 규칙용)
|
||||
|
||||
// 채번 타입 자동생성 상태
|
||||
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
|
||||
const hasGeneratedNumberingRef = useRef(false);
|
||||
|
||||
// tableName 추출 (props에서 전달받거나 config에서)
|
||||
const tableName = (props as any).tableName || (config as any).tableName;
|
||||
|
||||
// 수정 모드 여부 확인
|
||||
const originalData = (props as any).originalData || (props as any)._originalData;
|
||||
const isEditMode = originalData && Object.keys(originalData).length > 0;
|
||||
|
||||
// 채번 규칙인 경우 formData 변경 감지 (자기 자신 필드 제외)
|
||||
const formDataForNumbering = useMemo(() => {
|
||||
if (autoGeneration.type !== "numbering_rule") return "";
|
||||
// 자기 자신의 값은 제외 (무한 루프 방지)
|
||||
const { [columnName]: _, ...rest } = formData;
|
||||
return JSON.stringify(rest);
|
||||
}, [autoGeneration.type, formData, columnName]);
|
||||
|
||||
// 자동생성 로직
|
||||
useEffect(() => {
|
||||
const generateValue = async () => {
|
||||
// 자동생성 비활성화 또는 생성 중
|
||||
if (!autoGeneration.enabled || isGeneratingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 수정 모드에서는 자동생성 안함
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙인 경우: formData가 변경되었는지 확인
|
||||
const isNumberingRule = autoGeneration.type === "numbering_rule";
|
||||
const formDataChanged =
|
||||
isNumberingRule && formDataForNumbering !== lastFormDataRef.current && lastFormDataRef.current !== "";
|
||||
|
||||
// 이미 생성되었고, formData 변경이 아닌 경우 스킵
|
||||
if (hasGeneratedRef.current && !formDataChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 첫 생성 시: 값이 이미 있으면 스킵 (formData 변경 시에는 강제 재생성)
|
||||
if (!formDataChanged && value !== undefined && value !== null && value !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
isGeneratingRef.current = true;
|
||||
|
||||
try {
|
||||
// formData를 전달하여 날짜 컬럼 기준 생성 지원
|
||||
const generatedValue = await AutoGenerationUtils.generateValue(autoGeneration, columnName, formData);
|
||||
|
||||
if (generatedValue !== null && generatedValue !== undefined) {
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
onChange?.(generatedValue);
|
||||
hasGeneratedRef.current = true;
|
||||
|
||||
// formData 기록
|
||||
if (isNumberingRule) {
|
||||
lastFormDataRef.current = formDataForNumbering;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("자동생성 실패:", error);
|
||||
} finally {
|
||||
isGeneratingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
generateValue();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
|
||||
|
||||
// 채번 규칙 ID 캐싱
|
||||
const numberingRuleIdRef = useRef<string | null>(null);
|
||||
const lastCategoryValuesRef = useRef<string>("");
|
||||
// 사용자가 직접 입력 중인지 추적 (재생성 방지)
|
||||
const userEditedNumberingRef = useRef<boolean>(false);
|
||||
// 원래 수동 입력 부분이 있었는지 추적 (____가 있었으면 계속 편집 가능)
|
||||
const hadManualPartRef = useRef<boolean>(false);
|
||||
// 채번 템플릿 저장 (____가 포함된 원본 형태)
|
||||
const numberingTemplateRef = useRef<string>("");
|
||||
// 사용자가 수동 입력한 값 저장
|
||||
const [manualInputValue, setManualInputValue] = useState<string>("");
|
||||
|
||||
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
|
||||
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
|
||||
const categoryValuesForNumbering = useMemo(() => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
if (inputType !== "numbering") return "";
|
||||
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
|
||||
const categoryFields: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(formData)) {
|
||||
// 현재 채번 필드(columnName)는 제외
|
||||
if (key === columnName) continue;
|
||||
if (typeof val === "string" && val) {
|
||||
categoryFields[key] = val;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(categoryFields);
|
||||
}, [config.inputType, config.type, formData, columnName]);
|
||||
|
||||
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
|
||||
useEffect(() => {
|
||||
const generateNumberingCode = async () => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
|
||||
// numbering 타입이 아니면 스킵
|
||||
if (inputType !== "numbering") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 수정 모드에서는 자동생성 안함
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 생성 중이면 스킵
|
||||
if (isGeneratingNumbering) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자가 직접 편집한 경우 재생성 안함 (단, 카테고리 변경 시에는 재생성)
|
||||
const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current;
|
||||
if (userEditedNumberingRef.current && !categoryChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵
|
||||
if (hasGeneratedNumberingRef.current && !categoryChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 첫 생성 시: 값이 이미 있고 카테고리 변경이 아니면 스킵
|
||||
if (!categoryChanged && value !== undefined && value !== null && value !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
// tableName과 columnName이 필요
|
||||
if (!tableName || !columnName) {
|
||||
console.warn("채번 타입: tableName 또는 columnName이 없습니다", { tableName, columnName });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingNumbering(true);
|
||||
|
||||
try {
|
||||
// 채번 규칙 ID 캐싱 (한 번만 조회)
|
||||
if (!numberingRuleIdRef.current) {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const columnsResponse = await getTableColumns(tableName);
|
||||
|
||||
if (!columnsResponse.success || !columnsResponse.data) {
|
||||
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = columnsResponse.data.columns || columnsResponse.data;
|
||||
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
|
||||
|
||||
if (!targetColumn) {
|
||||
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
// detailSettings에서 numberingRuleId 추출
|
||||
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(targetColumn.detailSettings);
|
||||
numberingRuleIdRef.current = parsed.numberingRuleId || null;
|
||||
} catch {
|
||||
// JSON 파싱 실패
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numberingRuleId = numberingRuleIdRef.current;
|
||||
|
||||
if (!numberingRuleId) {
|
||||
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성)
|
||||
const previewResponse = await previewNumberingCode(numberingRuleId, formData);
|
||||
|
||||
if (previewResponse.success && previewResponse.data?.generatedCode) {
|
||||
const generatedCode = previewResponse.data.generatedCode;
|
||||
hasGeneratedNumberingRef.current = true;
|
||||
lastCategoryValuesRef.current = categoryValuesForNumbering;
|
||||
|
||||
// 수동 입력 부분이 있는 경우
|
||||
if (generatedCode.includes("____")) {
|
||||
hadManualPartRef.current = true;
|
||||
const oldTemplate = numberingTemplateRef.current;
|
||||
numberingTemplateRef.current = generatedCode;
|
||||
|
||||
// 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지
|
||||
if (oldTemplate && oldTemplate !== generatedCode) {
|
||||
// 템플릿이 변경되었지만 사용자 입력값은 유지
|
||||
const templateParts = generatedCode.split("____");
|
||||
const templatePrefix = templateParts[0] || "";
|
||||
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
|
||||
|
||||
// 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지)
|
||||
// 참고: setManualInputValue는 호출하지 않음 (기존 값 유지)
|
||||
const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix;
|
||||
|
||||
// 사용자가 입력한 적이 없으면 템플릿 그대로
|
||||
if (!userEditedNumberingRef.current) {
|
||||
setAutoGeneratedValue(generatedCode);
|
||||
onChange?.(generatedCode);
|
||||
}
|
||||
// 사용자가 입력한 적이 있으면 입력값 유지하며 템플릿만 변경
|
||||
// (manualInputValue 상태는 유지되므로 UI에서 자동 반영)
|
||||
} else {
|
||||
// 첫 생성
|
||||
setAutoGeneratedValue(generatedCode);
|
||||
onChange?.(generatedCode);
|
||||
userEditedNumberingRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// 수동 입력 부분 없음
|
||||
setAutoGeneratedValue(generatedCode);
|
||||
onChange?.(generatedCode);
|
||||
userEditedNumberingRef.current = false;
|
||||
}
|
||||
|
||||
// 채번 코드 생성 성공
|
||||
} else {
|
||||
console.warn("채번 코드 생성 실패:", previewResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 자동생성 오류:", error);
|
||||
} finally {
|
||||
setIsGeneratingNumbering(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateNumberingCode();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
|
||||
|
||||
// 실제 표시할 값 (자동생성 값 또는 props value)
|
||||
const displayValue = autoGeneratedValue ?? value;
|
||||
|
||||
// 조건부 렌더링 체크
|
||||
// TODO: conditional 처리 로직 추가
|
||||
|
||||
// 타입별 입력 컴포넌트 렌더링
|
||||
const renderInput = () => {
|
||||
const inputType = config.inputType || config.type || "text";
|
||||
switch (inputType) {
|
||||
case "text":
|
||||
return (
|
||||
<TextInput
|
||||
value={displayValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null); // 사용자 입력 시 자동생성 값 초기화
|
||||
onChange?.(v);
|
||||
}}
|
||||
format={config.format}
|
||||
mask={config.mask}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<NumberInput
|
||||
value={typeof displayValue === "number" ? displayValue : undefined}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v ?? 0);
|
||||
}}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "password":
|
||||
return (
|
||||
<PasswordInput
|
||||
value={typeof displayValue === "string" ? displayValue : ""}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "slider":
|
||||
return (
|
||||
<SliderInput
|
||||
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
min={config.min}
|
||||
max={config.max}
|
||||
step={config.step}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "color":
|
||||
return (
|
||||
<ColorInput
|
||||
value={typeof displayValue === "string" ? displayValue : "#000000"}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<TextareaInput
|
||||
value={displayValue as string}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
placeholder={config.placeholder}
|
||||
rows={config.rows}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "numbering": {
|
||||
// 채번 타입: ____ 부분만 편집 가능하게 처리
|
||||
const template = numberingTemplateRef.current;
|
||||
const canEdit = hadManualPartRef.current && template;
|
||||
|
||||
// 채번 필드 렌더링
|
||||
|
||||
// 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음)
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<TextInput
|
||||
value={displayValue || ""}
|
||||
onChange={() => {}}
|
||||
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
|
||||
readonly={true}
|
||||
disabled={disabled || isGeneratingNumbering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 템플릿에서 prefix와 suffix 추출
|
||||
const templateParts = template.split("____");
|
||||
const templatePrefix = templateParts[0] || "";
|
||||
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center rounded-md border">
|
||||
{/* 고정 접두어 */}
|
||||
{templatePrefix && (
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
|
||||
{templatePrefix}
|
||||
</span>
|
||||
)}
|
||||
{/* 편집 가능한 부분 */}
|
||||
<input
|
||||
type="text"
|
||||
value={manualInputValue}
|
||||
onChange={(e) => {
|
||||
const newUserInput = e.target.value;
|
||||
setManualInputValue(newUserInput);
|
||||
|
||||
// 전체 값 조합
|
||||
const newValue = templatePrefix + newUserInput + templateSuffix;
|
||||
userEditedNumberingRef.current = true;
|
||||
setAutoGeneratedValue(newValue);
|
||||
onChange?.(newValue);
|
||||
}}
|
||||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
|
||||
disabled={disabled || isGeneratingNumbering}
|
||||
/>
|
||||
{/* 고정 접미어 */}
|
||||
{templateSuffix && (
|
||||
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
|
||||
{templateSuffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
value={displayValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="flex-shrink-0 text-sm font-medium"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">{renderInput()}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedInput.displayName = "UnifiedInput";
|
||||
|
||||
export default UnifiedInput;
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedLayout
|
||||
*
|
||||
* 통합 레이아웃 컴포넌트
|
||||
* - grid: 그리드 레이아웃
|
||||
* - split: 분할 레이아웃
|
||||
* - flex: 플렉스 레이아웃
|
||||
* - divider: 구분선
|
||||
* - screen-embed: 화면 임베딩
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedLayoutProps } from "@/types/unified-components";
|
||||
import { GripVertical, GripHorizontal } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 그리드 레이아웃 컴포넌트 (12컬럼 시스템)
|
||||
*
|
||||
* 사용법:
|
||||
* - columns: 컬럼 수 (기본 12, 전통적 그리드)
|
||||
* - colSpan: 자식 요소별 span 지정 시 사용
|
||||
* - Tailwind의 grid-cols-12 기반
|
||||
*/
|
||||
const GridLayout = forwardRef<HTMLDivElement, {
|
||||
columns?: number; // 12컬럼 시스템에서 몇 컬럼으로 나눌지 (1-12)
|
||||
gap?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
use12Column?: boolean; // 12컬럼 시스템 사용 여부
|
||||
}>(({ columns = 12, gap = "16px", children, className, use12Column = true }, ref) => {
|
||||
// 12컬럼 그리드 클래스 매핑
|
||||
const gridColsClass: Record<number, string> = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-3",
|
||||
4: "grid-cols-4",
|
||||
5: "grid-cols-5",
|
||||
6: "grid-cols-6",
|
||||
7: "grid-cols-7",
|
||||
8: "grid-cols-8",
|
||||
9: "grid-cols-9",
|
||||
10: "grid-cols-10",
|
||||
11: "grid-cols-11",
|
||||
12: "grid-cols-12",
|
||||
};
|
||||
|
||||
// 12컬럼 시스템 사용 시
|
||||
if (use12Column) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid",
|
||||
gridColsClass[columns] || "grid-cols-12",
|
||||
className
|
||||
)}
|
||||
style={{ gap }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 방식 (동적 컬럼 수)
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("grid", className)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
GridLayout.displayName = "GridLayout";
|
||||
|
||||
/**
|
||||
* 분할 레이아웃 컴포넌트 (리사이즈 가능)
|
||||
*/
|
||||
const SplitLayout = forwardRef<HTMLDivElement, {
|
||||
direction?: "horizontal" | "vertical";
|
||||
splitRatio?: number[];
|
||||
gap?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({ direction = "horizontal", splitRatio = [50, 50], gap = "8px", children, className }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [ratio, setRatio] = useState(splitRatio);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const childArray = React.Children.toArray(children);
|
||||
const isHorizontal = direction === "horizontal";
|
||||
|
||||
// 리사이저 드래그 시작
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
|
||||
const startPos = isHorizontal ? e.clientX : e.clientY;
|
||||
const startRatio = [...ratio];
|
||||
const container = containerRef.current;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!container) return;
|
||||
|
||||
const containerSize = isHorizontal ? container.offsetWidth : container.offsetHeight;
|
||||
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
|
||||
const delta = currentPos - startPos;
|
||||
const deltaPercent = (delta / containerSize) * 100;
|
||||
|
||||
const newFirst = Math.max(10, Math.min(90, startRatio[0] + deltaPercent));
|
||||
const newSecond = 100 - newFirst;
|
||||
|
||||
setRatio([newFirst, newSecond]);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [isHorizontal, ratio]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
className={cn(
|
||||
"flex",
|
||||
isHorizontal ? "flex-row" : "flex-col",
|
||||
className
|
||||
)}
|
||||
style={{ gap }}
|
||||
>
|
||||
{/* 첫 번째 패널 */}
|
||||
<div
|
||||
className="overflow-auto"
|
||||
style={{
|
||||
[isHorizontal ? "width" : "height"]: `${ratio[0]}%`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{childArray[0]}
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-border hover:bg-primary/20 transition-colors",
|
||||
isHorizontal ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
|
||||
isDragging && "bg-primary/30"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{isHorizontal ? (
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 두 번째 패널 */}
|
||||
<div
|
||||
className="overflow-auto flex-1"
|
||||
style={{
|
||||
[isHorizontal ? "width" : "height"]: `${ratio[1]}%`,
|
||||
}}
|
||||
>
|
||||
{childArray[1]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SplitLayout.displayName = "SplitLayout";
|
||||
|
||||
/**
|
||||
* 플렉스 레이아웃 컴포넌트
|
||||
*/
|
||||
const FlexLayout = forwardRef<HTMLDivElement, {
|
||||
direction?: "horizontal" | "vertical";
|
||||
gap?: string;
|
||||
wrap?: boolean;
|
||||
justify?: "start" | "center" | "end" | "between" | "around";
|
||||
align?: "start" | "center" | "end" | "stretch";
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}>(({
|
||||
direction = "horizontal",
|
||||
gap = "16px",
|
||||
wrap = false,
|
||||
justify = "start",
|
||||
align = "stretch",
|
||||
children,
|
||||
className
|
||||
}, ref) => {
|
||||
const justifyMap = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
between: "space-between",
|
||||
around: "space-around",
|
||||
};
|
||||
|
||||
const alignMap = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
stretch: "stretch",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex", className)}
|
||||
style={{
|
||||
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||
flexWrap: wrap ? "wrap" : "nowrap",
|
||||
justifyContent: justifyMap[justify],
|
||||
alignItems: alignMap[align],
|
||||
gap,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
FlexLayout.displayName = "FlexLayout";
|
||||
|
||||
/**
|
||||
* 구분선 컴포넌트
|
||||
*/
|
||||
const DividerLayout = forwardRef<HTMLDivElement, {
|
||||
direction?: "horizontal" | "vertical";
|
||||
className?: string;
|
||||
}>(({ direction = "horizontal", className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-border",
|
||||
direction === "horizontal" ? "h-px w-full my-4" : "w-px h-full mx-4",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
DividerLayout.displayName = "DividerLayout";
|
||||
|
||||
/**
|
||||
* 화면 임베딩 컴포넌트
|
||||
*/
|
||||
const ScreenEmbedLayout = forwardRef<HTMLDivElement, {
|
||||
screenId?: number;
|
||||
className?: string;
|
||||
}>(({ screenId, className }, ref) => {
|
||||
if (!screenId) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-32 border-2 border-dashed rounded-lg text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
화면을 선택하세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: 실제 화면 임베딩 로직 구현
|
||||
// InteractiveScreenViewer와 연동 필요
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border rounded-lg p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
임베딩된 화면 (ID: {screenId})
|
||||
</div>
|
||||
<div className="h-48 bg-muted/30 rounded flex items-center justify-center">
|
||||
{/* 여기에 InteractiveScreenViewer 렌더링 */}
|
||||
화면 내용이 여기에 표시됩니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ScreenEmbedLayout.displayName = "ScreenEmbedLayout";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedLayout 컴포넌트
|
||||
*/
|
||||
export const UnifiedLayout = forwardRef<HTMLDivElement, UnifiedLayoutProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { type: "grid" as const, columns: 2 };
|
||||
|
||||
// 타입별 레이아웃 렌더링
|
||||
const renderLayout = () => {
|
||||
const layoutType = config.type || "grid";
|
||||
switch (layoutType) {
|
||||
case "grid":
|
||||
return (
|
||||
<GridLayout
|
||||
columns={config.columns}
|
||||
gap={config.gap}
|
||||
>
|
||||
{children}
|
||||
</GridLayout>
|
||||
);
|
||||
|
||||
case "split":
|
||||
return (
|
||||
<SplitLayout
|
||||
direction={config.direction}
|
||||
splitRatio={config.splitRatio}
|
||||
gap={config.gap}
|
||||
>
|
||||
{children}
|
||||
</SplitLayout>
|
||||
);
|
||||
|
||||
case "flex":
|
||||
return (
|
||||
<FlexLayout
|
||||
direction={config.direction}
|
||||
gap={config.gap}
|
||||
>
|
||||
{children}
|
||||
</FlexLayout>
|
||||
);
|
||||
|
||||
case "divider":
|
||||
return (
|
||||
<DividerLayout
|
||||
direction={config.direction}
|
||||
/>
|
||||
);
|
||||
|
||||
case "screen-embed":
|
||||
return (
|
||||
<ScreenEmbedLayout
|
||||
screenId={config.screenId}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<GridLayout columns={config.columns} gap={config.gap}>
|
||||
{children}
|
||||
</GridLayout>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{renderLayout()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedLayout.displayName = "UnifiedLayout";
|
||||
|
||||
export default UnifiedLayout;
|
||||
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedList
|
||||
*
|
||||
* 통합 리스트 컴포넌트
|
||||
* 기존 TableListComponent를 래핑하여 동일한 기능 제공
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useMemo } from "react";
|
||||
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
|
||||
import { UnifiedListProps } from "@/types/unified-components";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedList 컴포넌트
|
||||
* 기존 TableListComponent의 모든 기능을 그대로 사용
|
||||
*/
|
||||
export const UnifiedList = forwardRef<HTMLDivElement, UnifiedListProps>((props, ref) => {
|
||||
const { id, style, size, config: configProp, onRowSelect } = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || {
|
||||
viewMode: "table" as const,
|
||||
source: "static" as const,
|
||||
columns: [],
|
||||
};
|
||||
|
||||
// 테이블명 추출 (여러 가능한 경로에서 시도)
|
||||
const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName;
|
||||
|
||||
// columns 형식 변환 (UnifiedListConfigPanel 형식 -> TableListComponent 형식)
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
(config.columns || []).map((col: any, index: number) => ({
|
||||
columnName: col.key || col.field || "",
|
||||
displayName: col.title || col.header || col.key || col.field || "",
|
||||
width: col.width ? parseInt(col.width, 10) : undefined,
|
||||
visible: true,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
align: "left" as const,
|
||||
order: index,
|
||||
isEntityJoin: col.isJoinColumn || false,
|
||||
thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true)
|
||||
})),
|
||||
[config.columns],
|
||||
);
|
||||
|
||||
// TableListComponent에 전달할 component 객체 생성
|
||||
const componentObj = useMemo(
|
||||
() => ({
|
||||
id: id || "unified-list",
|
||||
type: "table-list",
|
||||
config: {
|
||||
selectedTable: tableName,
|
||||
tableName: tableName,
|
||||
columns: tableColumns,
|
||||
displayMode: config.viewMode === "card" ? "card" : "table",
|
||||
cardConfig: {
|
||||
idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id",
|
||||
titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "",
|
||||
subtitleColumn: config.cardConfig?.subtitleColumn || undefined,
|
||||
descriptionColumn: config.cardConfig?.descriptionColumn || undefined,
|
||||
imageColumn: config.cardConfig?.imageColumn || undefined,
|
||||
cardsPerRow: config.cardConfig?.cardsPerRow || 3,
|
||||
cardSpacing: 16,
|
||||
showActions: false,
|
||||
},
|
||||
showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김
|
||||
showFooter: false,
|
||||
checkbox: {
|
||||
enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장)
|
||||
position: "left" as const,
|
||||
showHeader: true,
|
||||
},
|
||||
height: "auto" as const, // auto로 변경하여 스크롤 가능하게
|
||||
autoWidth: true,
|
||||
stickyHeader: true,
|
||||
autoLoad: true,
|
||||
horizontalScroll: {
|
||||
enabled: true,
|
||||
minColumnWidth: 100,
|
||||
maxColumnWidth: 300,
|
||||
},
|
||||
pagination: {
|
||||
enabled: config.pagination !== false,
|
||||
pageSize: config.pageSize || 10,
|
||||
position: "bottom" as const,
|
||||
showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능
|
||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
||||
},
|
||||
filter: {
|
||||
enabled: false, // 필터 비활성화 (필요시 활성화)
|
||||
position: "top" as const,
|
||||
searchPlaceholder: "검색...",
|
||||
},
|
||||
actions: {
|
||||
enabled: false,
|
||||
items: [],
|
||||
},
|
||||
tableStyle: {
|
||||
striped: false,
|
||||
bordered: true,
|
||||
hover: true,
|
||||
compact: false,
|
||||
},
|
||||
toolbar: {
|
||||
showRefresh: true,
|
||||
showExport: false,
|
||||
showColumnToggle: false,
|
||||
},
|
||||
},
|
||||
style: {},
|
||||
gridColumns: 1,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
tableName,
|
||||
tableColumns,
|
||||
config.viewMode,
|
||||
config.pagination,
|
||||
config.pageSize,
|
||||
config.cardConfig,
|
||||
onRowSelect,
|
||||
],
|
||||
);
|
||||
|
||||
// 테이블이 없으면 안내 메시지
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
|
||||
style={{
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
}}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">테이블이 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col overflow-auto"
|
||||
style={{
|
||||
width: size?.width || style?.width || "100%",
|
||||
height: size?.height || style?.height || 400,
|
||||
}}
|
||||
>
|
||||
<TableListComponent
|
||||
component={componentObj}
|
||||
tableName={tableName}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onSelectedRowsChange={
|
||||
onRowSelect
|
||||
? (_, selectedData) => {
|
||||
onRowSelect(selectedData);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnifiedList.displayName = "UnifiedList";
|
||||
|
|
@ -0,0 +1,575 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedMedia
|
||||
*
|
||||
* 통합 미디어 컴포넌트
|
||||
* - file: 파일 업로드
|
||||
* - image: 이미지 업로드/표시
|
||||
* - video: 비디오
|
||||
* - audio: 오디오
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedMediaProps } from "@/types/unified-components";
|
||||
import { Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* 파일 크기 포맷팅
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 타입 아이콘 가져오기
|
||||
*/
|
||||
function getFileIcon(type: string) {
|
||||
if (type.startsWith("image/")) return ImageIcon;
|
||||
if (type.startsWith("video/")) return Video;
|
||||
if (type.startsWith("audio/")) return Music;
|
||||
return File;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 컴포넌트
|
||||
*/
|
||||
const FileUploader = forwardRef<HTMLDivElement, {
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
disabled?: boolean;
|
||||
uploadEndpoint?: string;
|
||||
className?: string;
|
||||
}>(({
|
||||
value,
|
||||
onChange,
|
||||
multiple = false,
|
||||
accept = "*",
|
||||
maxSize = 10485760, // 10MB
|
||||
disabled,
|
||||
uploadEndpoint = "/api/upload",
|
||||
className
|
||||
}, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const files = Array.isArray(value) ? value : value ? [value] : [];
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
const fileArray = Array.from(selectedFiles);
|
||||
|
||||
// 크기 검증
|
||||
for (const file of fileArray) {
|
||||
if (file.size > maxSize) {
|
||||
setError(`파일 크기가 ${formatFileSize(maxSize)}를 초과합니다: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
for (const file of fileArray) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(uploadEndpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`업로드 실패: ${file.name}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.url) {
|
||||
uploadedUrls.push(data.url);
|
||||
} else if (data.filePath) {
|
||||
uploadedUrls.push(data.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
onChange?.([...files, ...uploadedUrls]);
|
||||
} else {
|
||||
onChange?.(uploadedUrls[0] || "");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "업로드 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [files, multiple, maxSize, uploadEndpoint, onChange]);
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
}, [handleFileSelect]);
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
onChange?.(multiple ? newFiles : "");
|
||||
}, [files, multiple, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("space-y-3", className)}>
|
||||
{/* 업로드 영역 */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-6 text-center transition-colors",
|
||||
isDragging && "border-primary bg-primary/5",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!disabled && "cursor-pointer hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<span className="text-sm text-muted-foreground">업로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-primary">클릭</span>
|
||||
<span className="text-muted-foreground"> 또는 파일을 드래그하세요</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
최대 {formatFileSize(maxSize)}
|
||||
{accept !== "*" && ` (${accept})`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-2 bg-muted/50 rounded-md"
|
||||
>
|
||||
<File className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm truncate">{file.split("/").pop()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
FileUploader.displayName = "FileUploader";
|
||||
|
||||
/**
|
||||
* 이미지 업로드/표시 컴포넌트
|
||||
*/
|
||||
const ImageUploader = forwardRef<HTMLDivElement, {
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
preview?: boolean;
|
||||
disabled?: boolean;
|
||||
uploadEndpoint?: string;
|
||||
className?: string;
|
||||
}>(({
|
||||
value,
|
||||
onChange,
|
||||
multiple = false,
|
||||
accept = "image/*",
|
||||
maxSize = 10485760,
|
||||
preview = true,
|
||||
disabled,
|
||||
uploadEndpoint = "/api/upload",
|
||||
className
|
||||
}, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const images = Array.isArray(value) ? value : value ? [value] : [];
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileSelect = useCallback(async (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const fileArray = Array.from(selectedFiles);
|
||||
const uploadedUrls: string[] = [];
|
||||
|
||||
for (const file of fileArray) {
|
||||
// 미리보기 생성
|
||||
if (preview) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setPreviewUrl(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(uploadEndpoint, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.url) {
|
||||
uploadedUrls.push(data.url);
|
||||
} else if (data.filePath) {
|
||||
uploadedUrls.push(data.filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
onChange?.([...images, ...uploadedUrls]);
|
||||
} else {
|
||||
onChange?.(uploadedUrls[0] || "");
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [images, multiple, preview, uploadEndpoint, onChange]);
|
||||
|
||||
// 이미지 삭제 핸들러
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
const newImages = images.filter((_, i) => i !== index);
|
||||
onChange?.(multiple ? newImages : "");
|
||||
}, [images, multiple, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("space-y-3", className)}>
|
||||
{/* 이미지 미리보기 */}
|
||||
{preview && images.length > 0 && (
|
||||
<div className={cn(
|
||||
"grid gap-2",
|
||||
multiple ? "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" : "grid-cols-1"
|
||||
)}>
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className="relative group aspect-square rounded-lg overflow-hidden border">
|
||||
<img
|
||||
src={src}
|
||||
alt={`이미지 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => window.open(src, "_blank")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
{(!images.length || multiple) && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-4 text-center transition-colors",
|
||||
isDragging && "border-primary bg-primary/5",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!disabled && "cursor-pointer hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFileSelect(e.dataTransfer.files); }}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
<span className="text-sm text-muted-foreground">업로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
이미지 {multiple ? "추가" : "선택"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ImageUploader.displayName = "ImageUploader";
|
||||
|
||||
/**
|
||||
* 비디오 컴포넌트
|
||||
*/
|
||||
const VideoPlayer = forwardRef<HTMLDivElement, {
|
||||
value?: string;
|
||||
className?: string;
|
||||
}>(({ value, className }, ref) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-video flex items-center justify-center border rounded-lg bg-muted/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Video className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("aspect-video rounded-lg overflow-hidden", className)}>
|
||||
<video
|
||||
src={value}
|
||||
controls
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
VideoPlayer.displayName = "VideoPlayer";
|
||||
|
||||
/**
|
||||
* 오디오 컴포넌트
|
||||
*/
|
||||
const AudioPlayer = forwardRef<HTMLDivElement, {
|
||||
value?: string;
|
||||
className?: string;
|
||||
}>(({ value, className }, ref) => {
|
||||
if (!value) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 flex items-center justify-center border rounded-lg bg-muted/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Music className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("", className)}>
|
||||
<audio
|
||||
src={value}
|
||||
controls
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AudioPlayer.displayName = "AudioPlayer";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedMedia 컴포넌트
|
||||
*/
|
||||
export const UnifiedMedia = forwardRef<HTMLDivElement, UnifiedMediaProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
readonly,
|
||||
disabled,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
value,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { type: "image" as const };
|
||||
|
||||
// 타입별 미디어 컴포넌트 렌더링
|
||||
const renderMedia = () => {
|
||||
const isDisabled = disabled || readonly;
|
||||
const mediaType = config.type || "image";
|
||||
|
||||
switch (mediaType) {
|
||||
case "file":
|
||||
return (
|
||||
<FileUploader
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
multiple={config.multiple}
|
||||
accept={config.accept}
|
||||
maxSize={config.maxSize}
|
||||
disabled={isDisabled}
|
||||
uploadEndpoint={config.uploadEndpoint}
|
||||
/>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return (
|
||||
<ImageUploader
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
multiple={config.multiple}
|
||||
accept={config.accept || "image/*"}
|
||||
maxSize={config.maxSize}
|
||||
preview={config.preview}
|
||||
disabled={isDisabled}
|
||||
uploadEndpoint={config.uploadEndpoint}
|
||||
/>
|
||||
);
|
||||
|
||||
case "video":
|
||||
return (
|
||||
<VideoPlayer
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<AudioPlayer
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<FileUploader
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="text-sm font-medium flex-shrink-0"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderMedia()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedMedia.displayName = "UnifiedMedia";
|
||||
|
||||
export default UnifiedMedia;
|
||||
|
||||
|
|
@ -0,0 +1,938 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedRepeater 컴포넌트
|
||||
*
|
||||
* 렌더링 모드:
|
||||
* - inline: 현재 테이블 컬럼 직접 입력
|
||||
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||
*
|
||||
* RepeaterTable 및 ItemSelectionModal 재사용
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
UnifiedRepeaterConfig,
|
||||
UnifiedRepeaterProps,
|
||||
RepeaterColumnConfig as UnifiedColumnConfig,
|
||||
DEFAULT_REPEATER_CONFIG,
|
||||
} from "@/types/unified-repeater";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
// modal-repeater-table 컴포넌트 재사용
|
||||
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
||||
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
|
||||
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
|
||||
|
||||
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
|
||||
declare global {
|
||||
interface Window {
|
||||
__unifiedRepeaterInstances?: Set<string>;
|
||||
}
|
||||
}
|
||||
|
||||
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||
config: propConfig,
|
||||
parentId,
|
||||
data: initialData,
|
||||
onDataChange,
|
||||
onRowClick,
|
||||
className,
|
||||
}) => {
|
||||
// 설정 병합
|
||||
const config: UnifiedRepeaterConfig = useMemo(
|
||||
() => ({
|
||||
...DEFAULT_REPEATER_CONFIG,
|
||||
...propConfig,
|
||||
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
|
||||
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
|
||||
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
|
||||
}),
|
||||
[propConfig],
|
||||
);
|
||||
|
||||
// 상태
|
||||
const [data, setData] = useState<any[]>(initialData || []);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
||||
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
||||
|
||||
// 소스 테이블 컬럼 라벨 매핑
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
|
||||
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
||||
|
||||
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
||||
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
||||
|
||||
// 동적 데이터 소스 상태
|
||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||
|
||||
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
|
||||
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
|
||||
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
|
||||
|
||||
const isModalMode = config.renderMode === "modal";
|
||||
|
||||
// 전역 리피터 등록
|
||||
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
||||
useEffect(() => {
|
||||
const targetTableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
if (targetTableName) {
|
||||
if (!window.__unifiedRepeaterInstances) {
|
||||
window.__unifiedRepeaterInstances = new Set();
|
||||
}
|
||||
window.__unifiedRepeaterInstances.add(targetTableName);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (targetTableName && window.__unifiedRepeaterInstances) {
|
||||
window.__unifiedRepeaterInstances.delete(targetTableName);
|
||||
}
|
||||
};
|
||||
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
||||
|
||||
// 저장 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleSaveEvent = async (event: CustomEvent) => {
|
||||
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
||||
const tableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
const eventParentId = event.detail?.parentId;
|
||||
const mainFormData = event.detail?.mainFormData;
|
||||
|
||||
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
||||
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
||||
|
||||
if (!tableName || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// UnifiedRepeater 저장 시작
|
||||
const saveInfo = {
|
||||
tableName,
|
||||
useCustomTable: config.useCustomTable,
|
||||
mainTableName: config.mainTableName,
|
||||
foreignKeyColumn: config.foreignKeyColumn,
|
||||
masterRecordId,
|
||||
dataLength: data.length
|
||||
});
|
||||
|
||||
try {
|
||||
// 테이블 유효 컬럼 조회
|
||||
let validColumns: Set<string> = new Set();
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const columns =
|
||||
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
|
||||
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
|
||||
} catch {
|
||||
console.warn("테이블 컬럼 정보 조회 실패");
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
|
||||
// 내부 필드 제거
|
||||
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
||||
|
||||
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
||||
let mergedData: Record<string, any>;
|
||||
if (config.useCustomTable && config.mainTableName) {
|
||||
// 커스텀 테이블: 리피터 데이터만 저장
|
||||
mergedData = { ...cleanRow };
|
||||
|
||||
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
||||
if (config.foreignKeyColumn) {
|
||||
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
||||
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
||||
const sourceColumn = config.foreignKeySourceColumn;
|
||||
let fkValue: any;
|
||||
|
||||
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
||||
// mainFormData에서 참조 컬럼 값 가져오기
|
||||
fkValue = mainFormData[sourceColumn];
|
||||
} else {
|
||||
// 기본: 마스터 레코드 ID 사용
|
||||
fkValue = masterRecordId;
|
||||
}
|
||||
|
||||
if (fkValue !== undefined && fkValue !== null) {
|
||||
mergedData[config.foreignKeyColumn] = fkValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 기존 방식: 메인 폼 데이터 병합
|
||||
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
||||
mergedData = {
|
||||
...mainFormDataWithoutId,
|
||||
...cleanRow,
|
||||
};
|
||||
}
|
||||
|
||||
// 유효하지 않은 컬럼 제거
|
||||
const filteredData: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(mergedData)) {
|
||||
if (validColumns.size === 0 || validColumns.has(key)) {
|
||||
filteredData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ UnifiedRepeater 저장 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.REPEATER_SAVE,
|
||||
async (payload) => {
|
||||
const tableName = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
if (payload.tableName === tableName) {
|
||||
await handleSaveEvent({ detail: payload } as CustomEvent);
|
||||
}
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
||||
};
|
||||
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
|
||||
|
||||
// 현재 테이블 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadCurrentTableColumnInfo = async () => {
|
||||
const tableName = config.dataSource?.tableName;
|
||||
if (!tableName) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
|
||||
const columnMap: Record<string, any> = {};
|
||||
columns.forEach((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
columnMap[name] = {
|
||||
inputType: col.inputType || col.input_type || col.webType || "text",
|
||||
displayName: col.displayName || col.display_name || col.label || name,
|
||||
detailSettings: col.detailSettings || col.detail_settings,
|
||||
};
|
||||
});
|
||||
setCurrentTableColumnInfo(columnMap);
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadCurrentTableColumnInfo();
|
||||
}, [config.dataSource?.tableName]);
|
||||
|
||||
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
|
||||
useEffect(() => {
|
||||
const resolveEntityReference = async () => {
|
||||
const tableName = config.dataSource?.tableName;
|
||||
const foreignKey = config.dataSource?.foreignKey;
|
||||
|
||||
if (!isModalMode || !tableName || !foreignKey) {
|
||||
// config에 저장된 값을 기본값으로 사용
|
||||
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
|
||||
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
|
||||
|
||||
if (fkColumn) {
|
||||
// column_labels의 reference_table 사용 (항상 최신값)
|
||||
const refTable =
|
||||
fkColumn.detailSettings?.referenceTable ||
|
||||
fkColumn.reference_table ||
|
||||
fkColumn.referenceTable ||
|
||||
config.dataSource?.sourceTable ||
|
||||
"";
|
||||
const refKey =
|
||||
fkColumn.detailSettings?.referenceColumn ||
|
||||
fkColumn.reference_column ||
|
||||
fkColumn.referenceColumn ||
|
||||
config.dataSource?.referenceKey ||
|
||||
"id";
|
||||
|
||||
setResolvedSourceTable(refTable);
|
||||
setResolvedReferenceKey(refKey);
|
||||
} else {
|
||||
// FK 컬럼을 찾지 못한 경우 config 값 사용
|
||||
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("엔티티 참조 정보 조회 실패:", error);
|
||||
// 오류 시 config 값 사용
|
||||
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
||||
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
||||
}
|
||||
};
|
||||
|
||||
resolveEntityReference();
|
||||
}, [
|
||||
config.dataSource?.tableName,
|
||||
config.dataSource?.foreignKey,
|
||||
config.dataSource?.sourceTable,
|
||||
config.dataSource?.referenceKey,
|
||||
isModalMode,
|
||||
]);
|
||||
|
||||
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
|
||||
// 🆕 카테고리 타입 컬럼도 함께 감지
|
||||
useEffect(() => {
|
||||
const loadSourceColumnLabels = async () => {
|
||||
if (!isModalMode || !resolvedSourceTable) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
|
||||
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
||||
|
||||
const labels: Record<string, string> = {};
|
||||
const categoryCols: string[] = [];
|
||||
|
||||
columns.forEach((col: any) => {
|
||||
const name = col.columnName || col.column_name || col.name;
|
||||
labels[name] = col.displayName || col.display_name || col.label || name;
|
||||
|
||||
// 🆕 카테고리 타입 컬럼 감지
|
||||
const inputType = col.inputType || col.input_type || "";
|
||||
if (inputType === "category") {
|
||||
categoryCols.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
setSourceColumnLabels(labels);
|
||||
setSourceCategoryColumns(categoryCols);
|
||||
} catch (error) {
|
||||
console.error("소스 컬럼 라벨 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadSourceColumnLabels();
|
||||
}, [resolvedSourceTable, isModalMode]);
|
||||
|
||||
// UnifiedColumnConfig → RepeaterColumnConfig 변환
|
||||
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
|
||||
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
|
||||
return config.columns
|
||||
.filter((col: UnifiedColumnConfig) => col.visible !== false)
|
||||
.map((col: UnifiedColumnConfig): RepeaterColumnConfig => {
|
||||
const colInfo = currentTableColumnInfo[col.key];
|
||||
const inputType = col.inputType || colInfo?.inputType || "text";
|
||||
|
||||
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
|
||||
if (col.isSourceDisplay) {
|
||||
const label = col.title || sourceColumnLabels[col.key] || col.key;
|
||||
return {
|
||||
field: `_display_${col.key}`,
|
||||
label,
|
||||
type: "text",
|
||||
editable: false,
|
||||
calculated: true,
|
||||
width: col.width === "auto" ? undefined : col.width,
|
||||
};
|
||||
}
|
||||
|
||||
// 일반 입력 컬럼
|
||||
let type: "text" | "number" | "date" | "select" | "category" = "text";
|
||||
if (inputType === "number" || inputType === "decimal") type = "number";
|
||||
else if (inputType === "date" || inputType === "datetime") type = "date";
|
||||
else if (inputType === "code") type = "select";
|
||||
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
||||
|
||||
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
||||
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
||||
let categoryRef: string | undefined;
|
||||
if (inputType === "category") {
|
||||
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
||||
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
||||
if (tableName) {
|
||||
categoryRef = `${tableName}.${col.key}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
field: col.key,
|
||||
label: col.title || colInfo?.displayName || col.key,
|
||||
type,
|
||||
editable: col.editable !== false,
|
||||
width: col.width === "auto" ? undefined : col.width,
|
||||
required: false,
|
||||
categoryRef, // 🆕 카테고리 참조 ID 전달
|
||||
hidden: col.hidden, // 🆕 히든 처리
|
||||
autoFill: col.autoFill, // 🆕 자동 입력 설정
|
||||
};
|
||||
});
|
||||
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
||||
|
||||
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
||||
useEffect(() => {
|
||||
const loadCategoryLabels = async () => {
|
||||
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
||||
const allCodes = new Set<string>();
|
||||
for (const row of data) {
|
||||
for (const col of sourceCategoryColumns) {
|
||||
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
||||
const val = row[`_display_${col}`] || row[col];
|
||||
if (val && typeof val === "string") {
|
||||
const codes = val
|
||||
.split(",")
|
||||
.map((c: string) => c.trim())
|
||||
.filter(Boolean);
|
||||
for (const code of codes) {
|
||||
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
||||
allCodes.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allCodes.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(allCodes),
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data.data) {
|
||||
setCategoryLabelMap((prev) => ({
|
||||
...prev,
|
||||
...response.data.data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryLabels();
|
||||
}, [data, sourceCategoryColumns]);
|
||||
|
||||
// 데이터 변경 핸들러
|
||||
const handleDataChange = useCallback(
|
||||
(newData: any[]) => {
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
||||
if (onDataChange) {
|
||||
const targetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
// 각 행에 _targetTable 추가
|
||||
const dataWithTarget = newData.map(row => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
||||
setAutoWidthTrigger((prev) => prev + 1);
|
||||
},
|
||||
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||
);
|
||||
|
||||
// 행 변경 핸들러
|
||||
const handleRowChange = useCallback(
|
||||
(index: number, newRow: any) => {
|
||||
const newData = [...data];
|
||||
newData[index] = newRow;
|
||||
setData(newData);
|
||||
|
||||
// 🆕 _targetTable 메타데이터 포함
|
||||
if (onDataChange) {
|
||||
const targetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: config.dataSource?.tableName;
|
||||
|
||||
if (targetTable) {
|
||||
const dataWithTarget = newData.map(row => ({
|
||||
...row,
|
||||
_targetTable: targetTable,
|
||||
}));
|
||||
onDataChange(dataWithTarget);
|
||||
} else {
|
||||
onDataChange(newData);
|
||||
}
|
||||
}
|
||||
},
|
||||
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||
);
|
||||
|
||||
// 행 삭제 핸들러
|
||||
const handleRowDelete = useCallback(
|
||||
(index: number) => {
|
||||
const newData = data.filter((_, i) => i !== index);
|
||||
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||
|
||||
// 선택 상태 업데이트
|
||||
const newSelected = new Set<number>();
|
||||
selectedRows.forEach((i) => {
|
||||
if (i < index) newSelected.add(i);
|
||||
else if (i > index) newSelected.add(i - 1);
|
||||
});
|
||||
setSelectedRows(newSelected);
|
||||
},
|
||||
[data, selectedRows, handleDataChange],
|
||||
);
|
||||
|
||||
// 일괄 삭제 핸들러
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
const newData = data.filter((_, index) => !selectedRows.has(index));
|
||||
handleDataChange(newData); // 🆕 handleDataChange 사용
|
||||
setSelectedRows(new Set());
|
||||
}, [data, selectedRows, handleDataChange]);
|
||||
|
||||
// 행 추가 (inline 모드)
|
||||
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
|
||||
const generateAutoFillValueSync = useCallback(
|
||||
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
|
||||
if (!col.autoFill || col.autoFill.type === "none") return undefined;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
switch (col.autoFill.type) {
|
||||
case "currentDate":
|
||||
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
|
||||
case "currentDateTime":
|
||||
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
||||
|
||||
case "sequence":
|
||||
return rowIndex + 1; // 1부터 시작하는 순번
|
||||
|
||||
case "numbering":
|
||||
// 채번은 별도 비동기 처리 필요
|
||||
return null; // null 반환하여 비동기 처리 필요함을 표시
|
||||
|
||||
case "fromMainForm":
|
||||
if (col.autoFill.sourceField && mainFormData) {
|
||||
return mainFormData[col.autoFill.sourceField];
|
||||
}
|
||||
return "";
|
||||
|
||||
case "fixed":
|
||||
return col.autoFill.fixedValue ?? "";
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 🆕 채번 API 호출 (비동기)
|
||||
const generateNumberingCode = useCallback(async (ruleId: string): Promise<string> => {
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
return result.data.generatedCode;
|
||||
}
|
||||
console.error("채번 실패:", result.error);
|
||||
return "";
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
return "";
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
||||
const handleAddRow = useCallback(async () => {
|
||||
if (isModalMode) {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
const newRow: any = { _id: `new_${Date.now()}` };
|
||||
const currentRowCount = data.length;
|
||||
|
||||
// 먼저 동기적 자동 입력 값 적용
|
||||
for (const col of config.columns) {
|
||||
const autoValue = generateAutoFillValueSync(col, currentRowCount);
|
||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||
// 채번 규칙: 즉시 API 호출
|
||||
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||
} else if (autoValue !== undefined) {
|
||||
newRow[col.key] = autoValue;
|
||||
} else {
|
||||
newRow[col.key] = "";
|
||||
}
|
||||
}
|
||||
|
||||
const newData = [...data, newRow];
|
||||
handleDataChange(newData);
|
||||
}
|
||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
|
||||
|
||||
// 모달에서 항목 선택 - 비동기로 변경
|
||||
const handleSelectItems = useCallback(
|
||||
async (items: Record<string, unknown>[]) => {
|
||||
const fkColumn = config.dataSource?.foreignKey;
|
||||
const currentRowCount = data.length;
|
||||
|
||||
// 채번이 필요한 컬럼 찾기
|
||||
const numberingColumns = config.columns.filter(
|
||||
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId
|
||||
);
|
||||
|
||||
const newRows = await Promise.all(
|
||||
items.map(async (item, index) => {
|
||||
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
||||
|
||||
// FK 값 저장 (resolvedReferenceKey 사용)
|
||||
if (fkColumn && item[resolvedReferenceKey]) {
|
||||
row[fkColumn] = item[resolvedReferenceKey];
|
||||
}
|
||||
|
||||
// 모든 컬럼 처리 (순서대로)
|
||||
for (const col of config.columns) {
|
||||
if (col.isSourceDisplay) {
|
||||
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
||||
row[`_display_${col.key}`] = item[col.key] || "";
|
||||
} else {
|
||||
// 자동 입력 값 적용
|
||||
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
|
||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||
// 채번 규칙: 즉시 API 호출
|
||||
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||
} else if (autoValue !== undefined) {
|
||||
row[col.key] = autoValue;
|
||||
} else if (row[col.key] === undefined) {
|
||||
// 입력 컬럼: 빈 값으로 초기화
|
||||
row[col.key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
})
|
||||
);
|
||||
|
||||
const newData = [...data, ...newRows];
|
||||
handleDataChange(newData);
|
||||
setModalOpen(false);
|
||||
},
|
||||
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode],
|
||||
);
|
||||
|
||||
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
|
||||
const sourceColumns = useMemo(() => {
|
||||
return config.columns
|
||||
.filter((col) => col.isSourceDisplay && col.visible !== false)
|
||||
.map((col) => col.key)
|
||||
.filter((key) => key && key !== "none");
|
||||
}, [config.columns]);
|
||||
|
||||
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const formData = customEvent.detail?.formData;
|
||||
|
||||
if (!formData || !dataRef.current.length) return;
|
||||
|
||||
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
|
||||
const processedData = await Promise.all(
|
||||
dataRef.current.map(async (row) => {
|
||||
const newRow = { ...row };
|
||||
|
||||
for (const key of Object.keys(newRow)) {
|
||||
const value = newRow[key];
|
||||
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
|
||||
// __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출
|
||||
const match = value.match(/__NUMBERING_RULE__(.+)__/);
|
||||
if (match) {
|
||||
const ruleId = match[1];
|
||||
try {
|
||||
const result = await allocateNumberingCode(ruleId);
|
||||
if (result.success && result.data?.generatedCode) {
|
||||
newRow[key] = result.data.generatedCode;
|
||||
} else {
|
||||
console.error("채번 실패:", result.error);
|
||||
newRow[key] = ""; // 채번 실패 시 빈 값
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 API 호출 실패:", error);
|
||||
newRow[key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newRow;
|
||||
}),
|
||||
);
|
||||
|
||||
// 처리된 데이터를 formData에 추가
|
||||
const fieldName = config.fieldName || "repeaterData";
|
||||
formData[fieldName] = processedData;
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribe = v2EventBus.subscribe(
|
||||
V2_EVENTS.FORM_SAVE_COLLECT,
|
||||
async (payload) => {
|
||||
// formData 객체가 있으면 데이터 수집
|
||||
const fakeEvent = {
|
||||
detail: { formData: payload.formData },
|
||||
} as CustomEvent;
|
||||
await handleBeforeFormSave(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
|
||||
};
|
||||
}, [config.fieldName]);
|
||||
|
||||
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
|
||||
useEffect(() => {
|
||||
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
|
||||
const handleComponentDataTransfer = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
|
||||
|
||||
// 이 컴포넌트가 대상인지 확인
|
||||
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
// 매핑 규칙이 있으면 적용
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
} else if (mode === "merge") {
|
||||
// 중복 제거 후 병합 (id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
|
||||
handleDataChange([...data, ...newItems]);
|
||||
} else {
|
||||
// 기본: append
|
||||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
||||
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
|
||||
const handleSplitPanelDataTransfer = async (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
});
|
||||
} else {
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
} else {
|
||||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
||||
// V2 EventBus 구독
|
||||
const unsubscribeComponent = v2EventBus.subscribe(
|
||||
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
||||
(payload) => {
|
||||
const fakeEvent = {
|
||||
detail: {
|
||||
targetComponentId: payload.targetComponentId,
|
||||
transferData: [payload.data],
|
||||
mappingRules: [],
|
||||
mode: "append",
|
||||
},
|
||||
} as CustomEvent;
|
||||
handleComponentDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
const unsubscribeSplitPanel = v2EventBus.subscribe(
|
||||
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
|
||||
(payload) => {
|
||||
const fakeEvent = {
|
||||
detail: {
|
||||
transferData: [payload.data],
|
||||
mappingRules: [],
|
||||
mode: "append",
|
||||
},
|
||||
} as CustomEvent;
|
||||
handleSplitPanelDataTransfer(fakeEvent);
|
||||
},
|
||||
{ componentId: `unified-repeater-${config.dataSource?.tableName}` }
|
||||
);
|
||||
|
||||
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
||||
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
|
||||
return () => {
|
||||
unsubscribeComponent();
|
||||
unsubscribeSplitPanel();
|
||||
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
};
|
||||
}, [parentId, config.fieldName, data, handleDataChange]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{data.length > 0 && `${data.length}개 항목`}
|
||||
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedRows.size > 0 && (
|
||||
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
선택 삭제 ({selectedRows.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repeater 테이블 */}
|
||||
<RepeaterTable
|
||||
columns={repeaterColumns}
|
||||
data={data}
|
||||
onDataChange={handleDataChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
activeDataSources={activeDataSources}
|
||||
onDataSourceChange={(field, optionId) => {
|
||||
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={autoWidthTrigger}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
categoryLabelMap={categoryLabelMap}
|
||||
/>
|
||||
|
||||
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
||||
{isModalMode && (
|
||||
<ItemSelectionModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
sourceTable={resolvedSourceTable}
|
||||
sourceColumns={sourceColumns}
|
||||
sourceSearchFields={sourceColumns}
|
||||
multiSelect={config.features?.multiSelect ?? true}
|
||||
modalTitle={config.modal?.title || "항목 검색"}
|
||||
alreadySelected={data}
|
||||
uniqueField={resolvedReferenceKey}
|
||||
onSelect={handleSelectItems}
|
||||
columnLabels={sourceColumnLabels}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedRepeater.displayName = "UnifiedRepeater";
|
||||
|
||||
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
||||
export const SafeUnifiedRepeater: React.FC<UnifiedRepeaterProps> = (props) => {
|
||||
return (
|
||||
<V2ErrorBoundary
|
||||
componentId={props.parentId || "unified-repeater"}
|
||||
componentType="UnifiedRepeater"
|
||||
fallbackStyle="compact"
|
||||
>
|
||||
<UnifiedRepeater {...props} />
|
||||
</V2ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedRepeater;
|
||||
|
|
@ -0,0 +1,765 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedSelect
|
||||
*
|
||||
* 통합 선택 컴포넌트
|
||||
* - dropdown: 드롭다운 선택
|
||||
* - radio: 라디오 버튼 그룹
|
||||
* - check: 체크박스 그룹
|
||||
* - tag: 태그 선택
|
||||
* - toggle: 토글 스위치
|
||||
* - swap: 스왑 선택 (좌우 이동)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
|
||||
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import UnifiedFormContext from "./UnifiedFormContext";
|
||||
|
||||
/**
|
||||
* 드롭다운 선택 컴포넌트
|
||||
*/
|
||||
const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||
options: SelectOption[];
|
||||
value?: string | string[];
|
||||
onChange?: (value: string | string[]) => void;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
multiple?: boolean;
|
||||
maxSelect?: number;
|
||||
allowClear?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "선택",
|
||||
searchable,
|
||||
multiple,
|
||||
maxSelect,
|
||||
allowClear = true,
|
||||
disabled,
|
||||
className
|
||||
}, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
||||
if (!searchable && !multiple) {
|
||||
return (
|
||||
<Select
|
||||
value={typeof value === "string" ? value : value?.[0] ?? ""}
|
||||
onValueChange={(v) => onChange?.(v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger ref={ref} className={cn("h-10", className)}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 검색 가능 또는 다중 선택 → Combobox 사용
|
||||
const selectedValues = useMemo(() => {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}, [value]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return selectedValues
|
||||
.map((v) => options.find((o) => o.value === v)?.label)
|
||||
.filter(Boolean) as string[];
|
||||
}, [selectedValues, options]);
|
||||
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
if (multiple) {
|
||||
const newValues = selectedValues.includes(selectedValue)
|
||||
? selectedValues.filter((v) => v !== selectedValue)
|
||||
: maxSelect && selectedValues.length >= maxSelect
|
||||
? selectedValues
|
||||
: [...selectedValues, selectedValue];
|
||||
onChange?.(newValues);
|
||||
} else {
|
||||
onChange?.(selectedValue);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [multiple, selectedValues, maxSelect, onChange]);
|
||||
|
||||
const handleClear = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange?.(multiple ? [] : "");
|
||||
}, [multiple, onChange]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("h-10 w-full justify-between font-normal", className)}
|
||||
>
|
||||
<span className="truncate flex-1 text-left">
|
||||
{selectedLabels.length > 0
|
||||
? multiple
|
||||
? `${selectedLabels.length}개 선택됨`
|
||||
: selectedLabels[0]
|
||||
: placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{allowClear && selectedValues.length > 0 && (
|
||||
<X
|
||||
className="h-4 w-4 opacity-50 hover:opacity-100"
|
||||
onClick={handleClear}
|
||||
/>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
// value는 CommandItem의 value (라벨)
|
||||
// search는 검색어
|
||||
if (!search) return 1;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
const normalizedSearch = search.toLowerCase();
|
||||
if (normalizedValue.includes(normalizedSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const displayLabel = option.label || option.value || "(빈 값)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={displayLabel}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{displayLabel}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
DropdownSelect.displayName = "DropdownSelect";
|
||||
|
||||
/**
|
||||
* 라디오 선택 컴포넌트
|
||||
*/
|
||||
const RadioSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ options, value, onChange, disabled, className }, ref) => {
|
||||
return (
|
||||
<RadioGroup
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className={cn("flex flex-wrap gap-4", className)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
|
||||
<Label htmlFor={`radio-${option.value}`} className="text-sm cursor-pointer">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
});
|
||||
RadioSelect.displayName = "RadioSelect";
|
||||
|
||||
/**
|
||||
* 체크박스 선택 컴포넌트
|
||||
*/
|
||||
const CheckSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
maxSelect?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
||||
const handleChange = useCallback((optionValue: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
if (maxSelect && value.length >= maxSelect) return;
|
||||
onChange?.([...value, optionValue]);
|
||||
} else {
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
}
|
||||
}, [value, maxSelect, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`check-${option.value}`}
|
||||
checked={value.includes(option.value)}
|
||||
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
|
||||
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
|
||||
/>
|
||||
<Label htmlFor={`check-${option.value}`} className="text-sm cursor-pointer">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CheckSelect.displayName = "CheckSelect";
|
||||
|
||||
/**
|
||||
* 태그 선택 컴포넌트
|
||||
*/
|
||||
const TagSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
maxSelect?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
|
||||
const handleToggle = useCallback((optionValue: string) => {
|
||||
const isSelected = value.includes(optionValue);
|
||||
if (isSelected) {
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
} else {
|
||||
if (maxSelect && value.length >= maxSelect) return;
|
||||
onChange?.([...value, optionValue]);
|
||||
}
|
||||
}, [value, maxSelect, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
|
||||
{options.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => !disabled && handleToggle(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
{isSelected && <X className="ml-1 h-3 w-3" />}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TagSelect.displayName = "TagSelect";
|
||||
|
||||
/**
|
||||
* 토글 선택 컴포넌트 (Boolean용)
|
||||
*/
|
||||
const ToggleSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ options, value, onChange, disabled, className }, ref) => {
|
||||
// 토글은 2개 옵션만 지원
|
||||
const [offOption, onOption] = options.length >= 2
|
||||
? [options[0], options[1]]
|
||||
: [{ value: "false", label: "아니오" }, { value: "true", label: "예" }];
|
||||
|
||||
const isOn = value === onOption.value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex items-center gap-3", className)}>
|
||||
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
|
||||
<Switch
|
||||
checked={isOn}
|
||||
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ToggleSelect.displayName = "ToggleSelect";
|
||||
|
||||
/**
|
||||
* 스왑 선택 컴포넌트 (좌우 이동 방식)
|
||||
*/
|
||||
const SwapSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
maxSelect?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}>(({ options, value = [], onChange, disabled, className }, ref) => {
|
||||
const available = useMemo(() =>
|
||||
options.filter((o) => !value.includes(o.value)),
|
||||
[options, value]
|
||||
);
|
||||
|
||||
const selected = useMemo(() =>
|
||||
options.filter((o) => value.includes(o.value)),
|
||||
[options, value]
|
||||
);
|
||||
|
||||
const handleMoveRight = useCallback((optionValue: string) => {
|
||||
onChange?.([...value, optionValue]);
|
||||
}, [value, onChange]);
|
||||
|
||||
const handleMoveLeft = useCallback((optionValue: string) => {
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
}, [value, onChange]);
|
||||
|
||||
const handleMoveAllRight = useCallback(() => {
|
||||
onChange?.(options.map((o) => o.value));
|
||||
}, [options, onChange]);
|
||||
|
||||
const handleMoveAllLeft = useCallback(() => {
|
||||
onChange?.([]);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex gap-2 items-stretch", className)}>
|
||||
{/* 왼쪽: 선택 가능 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-muted text-xs font-medium border-b">선택 가능</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
{available.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"p-2 text-sm rounded cursor-pointer hover:bg-accent",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => !disabled && handleMoveRight(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))}
|
||||
{available.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2">항목 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 이동 버튼 */}
|
||||
<div className="flex flex-col gap-1 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleMoveAllRight}
|
||||
disabled={disabled || available.length === 0}
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4 rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleMoveAllLeft}
|
||||
disabled={disabled || selected.length === 0}
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 선택됨 */}
|
||||
<div className="flex-1 border rounded-md">
|
||||
<div className="p-2 bg-primary/10 text-xs font-medium border-b">선택됨</div>
|
||||
<div className="p-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
{selected.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"p-2 text-sm rounded cursor-pointer hover:bg-accent flex justify-between items-center",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={() => !disabled && handleMoveLeft(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<X className="h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
))}
|
||||
{selected.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2">선택 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SwapSelect.displayName = "SwapSelect";
|
||||
|
||||
/**
|
||||
* 메인 UnifiedSelect 컴포넌트
|
||||
*/
|
||||
export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
required,
|
||||
readonly,
|
||||
disabled,
|
||||
style,
|
||||
size,
|
||||
config: configProp,
|
||||
value,
|
||||
onChange,
|
||||
tableName,
|
||||
columnName,
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
||||
|
||||
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||
|
||||
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
|
||||
const rawSource = config.source;
|
||||
const categoryTable = (config as any).categoryTable;
|
||||
const categoryColumn = (config as any).categoryColumn;
|
||||
|
||||
// category 소스 유지 (category_values_test 테이블에서 로드)
|
||||
const source = rawSource;
|
||||
const codeGroup = config.codeGroup;
|
||||
|
||||
const entityTable = config.entityTable;
|
||||
const entityValueColumn = config.entityValueColumn || config.entityValueField;
|
||||
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
|
||||
const table = config.table;
|
||||
const valueColumn = config.valueColumn;
|
||||
const labelColumn = config.labelColumn;
|
||||
const apiEndpoint = config.apiEndpoint;
|
||||
const staticOptions = config.options;
|
||||
|
||||
// 계층 코드 연쇄 선택 관련
|
||||
const hierarchical = config.hierarchical;
|
||||
const parentField = config.parentField;
|
||||
|
||||
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
||||
const formContext = useContext(UnifiedFormContext);
|
||||
|
||||
// 부모 필드의 값 계산
|
||||
const parentValue = useMemo(() => {
|
||||
if (!hierarchical || !parentField) return null;
|
||||
|
||||
// FormContext가 있으면 거기서 값 가져오기
|
||||
if (formContext) {
|
||||
const val = formContext.getValue(parentField);
|
||||
return val as string | null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [hierarchical, parentField, formContext]);
|
||||
|
||||
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
|
||||
useEffect(() => {
|
||||
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
|
||||
if (hierarchical && source === "code") {
|
||||
setOptionsLoaded(false);
|
||||
}
|
||||
}, [parentValue, hierarchical, source]);
|
||||
|
||||
useEffect(() => {
|
||||
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
|
||||
if (optionsLoaded && source !== "static") {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (source === "static") {
|
||||
setOptions(staticOptions || []);
|
||||
setOptionsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let fetchedOptions: SelectOption[] = [];
|
||||
|
||||
if (source === "code" && codeGroup) {
|
||||
// 계층 구조 사용 시 자식 코드만 로드
|
||||
if (hierarchical) {
|
||||
const params = new URLSearchParams();
|
||||
if (parentValue) {
|
||||
params.append("parentCodeValue", parentValue);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
|
||||
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (source === "db" && table) {
|
||||
// DB 테이블에서 로드
|
||||
const response = await apiClient.get(`/entity/${table}/options`, {
|
||||
params: {
|
||||
value: valueColumn || "id",
|
||||
label: labelColumn || "name",
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data;
|
||||
}
|
||||
} else if (source === "entity" && entityTable) {
|
||||
// 엔티티(참조 테이블)에서 로드
|
||||
const valueCol = entityValueColumn || "id";
|
||||
const labelCol = entityLabelColumn || "name";
|
||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||
params: {
|
||||
value: valueCol,
|
||||
label: labelCol,
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data;
|
||||
}
|
||||
} else if (source === "api" && apiEndpoint) {
|
||||
// 외부 API에서 로드
|
||||
const response = await apiClient.get(apiEndpoint);
|
||||
const data = response.data;
|
||||
if (Array.isArray(data)) {
|
||||
fetchedOptions = data;
|
||||
}
|
||||
} else if (source === "category") {
|
||||
// 카테고리에서 로드 (category_values_test 테이블)
|
||||
// tableName, columnName은 props에서 가져옴
|
||||
const catTable = categoryTable || tableName;
|
||||
const catColumn = categoryColumn || columnName;
|
||||
|
||||
if (catTable && catColumn) {
|
||||
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화하여 옵션으로 변환
|
||||
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
|
||||
const flattenTree = (items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], depth: number = 0): SelectOption[] => {
|
||||
const result: SelectOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||
result.push({
|
||||
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
||||
label: prefix + item.valueLabel,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fetchedOptions = flattenTree(data.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOptions(fetchedOptions);
|
||||
setOptionsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error("옵션 로딩 실패:", error);
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOptions();
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
||||
|
||||
// 모드별 컴포넌트 렌더링
|
||||
const renderSelect = () => {
|
||||
if (loading) {
|
||||
return <div className="h-10 flex items-center text-sm text-muted-foreground">로딩 중...</div>;
|
||||
}
|
||||
|
||||
const isDisabled = disabled || readonly;
|
||||
|
||||
switch (config.mode) {
|
||||
case "dropdown":
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="선택"
|
||||
searchable={config.searchable}
|
||||
multiple={config.multiple}
|
||||
maxSelect={config.maxSelect}
|
||||
allowClear={config.allowClear}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "radio":
|
||||
return (
|
||||
<RadioSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "check":
|
||||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tag":
|
||||
return (
|
||||
<TagSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
case "swap":
|
||||
return (
|
||||
<SwapSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const showLabel = label && style?.labelDisplay !== false;
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
}}
|
||||
>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
style={{
|
||||
fontSize: style?.labelFontSize,
|
||||
color: style?.labelColor,
|
||||
fontWeight: style?.labelFontWeight,
|
||||
marginBottom: style?.labelMarginBottom,
|
||||
}}
|
||||
className="text-sm font-medium flex-shrink-0"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderSelect()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UnifiedSelect.displayName = "UnifiedSelect";
|
||||
|
||||
export default UnifiedSelect;
|
||||
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedBiz 설정 패널
|
||||
* 통합 비즈니스 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface UnifiedBizConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const UnifiedBizConfigPanel: React.FC<UnifiedBizConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// 테이블 목록
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 컬럼 목록 (소스/대상/관련 테이블용)
|
||||
const [sourceColumns, setSourceColumns] = useState<ColumnOption[]>([]);
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
|
||||
const [relatedColumns, setRelatedColumns] = useState<ColumnOption[]>([]);
|
||||
const [categoryColumns, setCategoryColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getTables();
|
||||
setTables(data.map(t => ({
|
||||
tableName: t.tableName,
|
||||
displayName: t.displayName || t.tableName
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 소스 테이블 선택 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.sourceTable) {
|
||||
setSourceColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.sourceTable);
|
||||
setSourceColumns(data.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("소스 컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.sourceTable]);
|
||||
|
||||
// 대상 테이블 선택 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.targetTable) {
|
||||
setTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.targetTable);
|
||||
setTargetColumns(data.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("대상 컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// 관련 테이블 선택 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.relatedTable) {
|
||||
setRelatedColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.relatedTable);
|
||||
setRelatedColumns(data.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("관련 컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.relatedTable]);
|
||||
|
||||
// 카테고리 테이블 선택 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.tableName) {
|
||||
setCategoryColumns([]);
|
||||
return;
|
||||
}
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.tableName);
|
||||
setCategoryColumns(data.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("카테고리 컬럼 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 비즈니스 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">비즈니스 타입</Label>
|
||||
<Select
|
||||
value={config.bizType || config.type || "flow"}
|
||||
onValueChange={(value) => updateConfig("bizType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flow">플로우</SelectItem>
|
||||
<SelectItem value="rack">랙 구조</SelectItem>
|
||||
<SelectItem value="map">지도</SelectItem>
|
||||
<SelectItem value="numbering">채번 규칙</SelectItem>
|
||||
<SelectItem value="category">카테고리</SelectItem>
|
||||
<SelectItem value="data-mapping">데이터 매핑</SelectItem>
|
||||
<SelectItem value="related-data">관련 데이터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 플로우 설정 */}
|
||||
{config.bizType === "flow" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">플로우 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">플로우 ID</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.flowId || ""}
|
||||
onChange={(e) => updateConfig("flowId", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="플로우 ID"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="editable"
|
||||
checked={config.editable || false}
|
||||
onCheckedChange={(checked) => updateConfig("editable", checked)}
|
||||
/>
|
||||
<label htmlFor="editable" className="text-xs">편집 가능</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showMinimap"
|
||||
checked={config.showMinimap || false}
|
||||
onCheckedChange={(checked) => updateConfig("showMinimap", checked)}
|
||||
/>
|
||||
<label htmlFor="showMinimap" className="text-xs">미니맵 표시</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 랙 구조 설정 */}
|
||||
{config.bizType === "rack" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">랙 설정</Label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">행 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.rows || ""}
|
||||
onChange={(e) => updateConfig("rows", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="5"
|
||||
min="1"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">열 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.columns || ""}
|
||||
onChange={(e) => updateConfig("columns", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="10"
|
||||
min="1"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showLabels"
|
||||
checked={config.showLabels !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showLabels", checked)}
|
||||
/>
|
||||
<label htmlFor="showLabels" className="text-xs">라벨 표시</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 설정 */}
|
||||
{config.bizType === "numbering" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">채번 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">채번 규칙 ID</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.ruleId || ""}
|
||||
onChange={(e) => updateConfig("ruleId", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="규칙 ID"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">접두사</Label>
|
||||
<Input
|
||||
value={config.prefix || ""}
|
||||
onChange={(e) => updateConfig("prefix", e.target.value)}
|
||||
placeholder="예: INV-"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="autoGenerate"
|
||||
checked={config.autoGenerate !== false}
|
||||
onCheckedChange={(checked) => updateConfig("autoGenerate", checked)}
|
||||
/>
|
||||
<label htmlFor="autoGenerate" className="text-xs">자동 생성</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 설정 */}
|
||||
{config.bizType === "category" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">카테고리 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">카테고리 테이블</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("tableName", value);
|
||||
updateConfig("columnName", "");
|
||||
}}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.tableName && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
||||
<Select
|
||||
value={config.columnName || ""}
|
||||
onValueChange={(value) => updateConfig("columnName", value)}
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 매핑 설정 */}
|
||||
{config.bizType === "data-mapping" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">매핑 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">소스 테이블</Label>
|
||||
<Select
|
||||
value={config.sourceTable || ""}
|
||||
onValueChange={(value) => updateConfig("sourceTable", value)}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">대상 테이블</Label>
|
||||
<Select
|
||||
value={config.targetTable || ""}
|
||||
onValueChange={(value) => updateConfig("targetTable", value)}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 관련 데이터 설정 */}
|
||||
{config.bizType === "related-data" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">관련 데이터 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">관련 테이블</Label>
|
||||
<Select
|
||||
value={config.relatedTable || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("relatedTable", value);
|
||||
updateConfig("linkColumn", "");
|
||||
}}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.relatedTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">연결 컬럼</Label>
|
||||
<Select
|
||||
value={config.linkColumn || ""}
|
||||
onValueChange={(value) => updateConfig("linkColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relatedColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.buttonText || ""}
|
||||
onChange={(e) => updateConfig("buttonText", e.target.value)}
|
||||
placeholder="관련 데이터 보기"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedBizConfigPanel.displayName = "UnifiedBizConfigPanel";
|
||||
|
||||
export default UnifiedBizConfigPanel;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedDate 설정 패널
|
||||
* 통합 날짜 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface UnifiedDateConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const UnifiedDateConfigPanel: React.FC<UnifiedDateConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 날짜 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">날짜 타입</Label>
|
||||
<Select
|
||||
value={config.dateType || config.type || "date"}
|
||||
onValueChange={(value) => updateConfig("dateType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="time">시간</SelectItem>
|
||||
<SelectItem value="datetime">날짜+시간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 표시 형식 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 형식</Label>
|
||||
<Select
|
||||
value={config.format || "YYYY-MM-DD"}
|
||||
onValueChange={(value) => updateConfig("format", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
|
||||
<SelectItem value="YYYY/MM/DD">YYYY/MM/DD</SelectItem>
|
||||
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
|
||||
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
|
||||
<SelectItem value="YYYY년 MM월 DD일">YYYY년 MM월 DD일</SelectItem>
|
||||
{(config.dateType === "time" || config.dateType === "datetime") && (
|
||||
<>
|
||||
<SelectItem value="HH:mm">HH:mm</SelectItem>
|
||||
<SelectItem value="HH:mm:ss">HH:mm:ss</SelectItem>
|
||||
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
|
||||
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 날짜 범위 제한 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">날짜 범위 제한</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">최소 날짜</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={config.minDate || ""}
|
||||
onChange={(e) => updateConfig("minDate", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">최대 날짜</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={config.maxDate || ""}
|
||||
onChange={(e) => updateConfig("maxDate", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 추가 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">추가 옵션</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="range"
|
||||
checked={config.range || false}
|
||||
onCheckedChange={(checked) => updateConfig("range", checked)}
|
||||
/>
|
||||
<label htmlFor="range" className="text-xs">기간 선택 (시작~종료)</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showToday"
|
||||
checked={config.showToday !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showToday", checked)}
|
||||
/>
|
||||
<label htmlFor="showToday" className="text-xs">오늘 버튼 표시</label>
|
||||
</div>
|
||||
|
||||
{(config.dateType === "datetime" || config.dateType === "time") && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showSeconds"
|
||||
checked={config.showSeconds || false}
|
||||
onCheckedChange={(checked) => updateConfig("showSeconds", checked)}
|
||||
/>
|
||||
<label htmlFor="showSeconds" className="text-xs">초 단위 표시</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedDateConfigPanel.displayName = "UnifiedDateConfigPanel";
|
||||
|
||||
export default UnifiedDateConfigPanel;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedGroup 설정 패널
|
||||
* 통합 그룹 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface UnifiedGroupConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const UnifiedGroupConfigPanel: React.FC<UnifiedGroupConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 탭 관리
|
||||
const tabs = config.tabs || [];
|
||||
|
||||
const addTab = () => {
|
||||
const newTabs = [...tabs, { id: `tab-${Date.now()}`, label: "새 탭", content: "" }];
|
||||
updateConfig("tabs", newTabs);
|
||||
};
|
||||
|
||||
const updateTab = (index: number, field: string, value: string) => {
|
||||
const newTabs = [...tabs];
|
||||
newTabs[index] = { ...newTabs[index], [field]: value };
|
||||
updateConfig("tabs", newTabs);
|
||||
};
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const newTabs = tabs.filter((_: any, i: number) => i !== index);
|
||||
updateConfig("tabs", newTabs);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 그룹 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">그룹 타입</Label>
|
||||
<Select
|
||||
value={config.groupType || config.type || "section"}
|
||||
onValueChange={(value) => updateConfig("groupType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="section">섹션</SelectItem>
|
||||
<SelectItem value="tabs">탭</SelectItem>
|
||||
<SelectItem value="accordion">아코디언</SelectItem>
|
||||
<SelectItem value="card">카드 섹션</SelectItem>
|
||||
<SelectItem value="modal">모달</SelectItem>
|
||||
<SelectItem value="form-modal">폼 모달</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">제목</Label>
|
||||
<Input
|
||||
value={config.title || ""}
|
||||
onChange={(e) => updateConfig("title", e.target.value)}
|
||||
placeholder="그룹 제목"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 설정 */}
|
||||
{config.groupType === "tabs" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">탭 목록</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addTab}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{tabs.map((tab: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={tab.id || ""}
|
||||
onChange={(e) => updateTab(index, "id", e.target.value)}
|
||||
placeholder="ID"
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={tab.label || ""}
|
||||
onChange={(e) => updateTab(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeTab(index)}
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{tabs.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">
|
||||
탭을 추가해주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 섹션/아코디언 옵션 */}
|
||||
{(config.groupType === "section" || config.groupType === "accordion" || !config.groupType) && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
checked={config.collapsible || false}
|
||||
onCheckedChange={(checked) => updateConfig("collapsible", checked)}
|
||||
/>
|
||||
<label htmlFor="collapsible" className="text-xs">접기/펴기 가능</label>
|
||||
</div>
|
||||
|
||||
{config.collapsible && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={config.defaultOpen !== false}
|
||||
onCheckedChange={(checked) => updateConfig("defaultOpen", checked)}
|
||||
/>
|
||||
<label htmlFor="defaultOpen" className="text-xs">기본으로 펼침</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모달 옵션 */}
|
||||
{(config.groupType === "modal" || config.groupType === "form-modal") && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">모달 크기</Label>
|
||||
<Select
|
||||
value={config.modalSize || "md"}
|
||||
onValueChange={(value) => updateConfig("modalSize", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작게 (400px)</SelectItem>
|
||||
<SelectItem value="md">보통 (600px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (800px)</SelectItem>
|
||||
<SelectItem value="xl">매우 크게 (1000px)</SelectItem>
|
||||
<SelectItem value="full">전체 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="closeable"
|
||||
checked={config.closeable !== false}
|
||||
onCheckedChange={(checked) => updateConfig("closeable", checked)}
|
||||
/>
|
||||
<label htmlFor="closeable" className="text-xs">닫기 버튼 표시</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="backdrop"
|
||||
checked={config.backdrop !== false}
|
||||
onCheckedChange={(checked) => updateConfig("backdrop", checked)}
|
||||
/>
|
||||
<label htmlFor="backdrop" className="text-xs">배경 클릭으로 닫기</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 표시 여부 */}
|
||||
<Separator />
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showHeader", checked)}
|
||||
/>
|
||||
<label htmlFor="showHeader" className="text-xs">헤더 표시</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedGroupConfigPanel.displayName = "UnifiedGroupConfigPanel";
|
||||
|
||||
export default UnifiedGroupConfigPanel;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedHierarchy 설정 패널
|
||||
* 통합 계층 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
interface UnifiedHierarchyConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface ColumnOption {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const UnifiedHierarchyConfigPanel: React.FC<UnifiedHierarchyConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// 테이블 목록
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 컬럼 목록
|
||||
const [columns, setColumns] = useState<ColumnOption[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getTables();
|
||||
setTables(data.map(t => ({
|
||||
tableName: t.tableName,
|
||||
displayName: t.displayName || t.tableName
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 테이블 선택 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!config.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getColumns(config.tableName);
|
||||
setColumns(data.map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
displayName: c.displayName || c.columnName || c.column_name
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [config.tableName]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 계층 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">계층 타입</Label>
|
||||
<Select
|
||||
value={config.hierarchyType || config.type || "tree"}
|
||||
onValueChange={(value) => updateConfig("hierarchyType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tree">트리</SelectItem>
|
||||
<SelectItem value="org-chart">조직도</SelectItem>
|
||||
<SelectItem value="bom">BOM (Bill of Materials)</SelectItem>
|
||||
<SelectItem value="cascading">연쇄 선택박스</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 뷰 모드 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 방식</Label>
|
||||
<Select
|
||||
value={config.viewMode || "tree"}
|
||||
onValueChange={(value) => updateConfig("viewMode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tree">트리뷰</SelectItem>
|
||||
<SelectItem value="table">테이블</SelectItem>
|
||||
<SelectItem value="chart">차트</SelectItem>
|
||||
<SelectItem value="cascading">연쇄 드롭다운</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 데이터 소스 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||||
<Select
|
||||
value={config.dataSource || "static"}
|
||||
onValueChange={(value) => updateConfig("dataSource", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 데이터</SelectItem>
|
||||
<SelectItem value="db">데이터베이스</SelectItem>
|
||||
<SelectItem value="api">API</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* DB 설정 */}
|
||||
{config.dataSource === "db" && (
|
||||
<div className="space-y-3">
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">테이블</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("tableName", value);
|
||||
// 테이블 변경 시 컬럼 초기화
|
||||
updateConfig("idColumn", "");
|
||||
updateConfig("parentIdColumn", "");
|
||||
updateConfig("labelColumn", "");
|
||||
}}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
{config.tableName && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">ID 컬럼</Label>
|
||||
<Select
|
||||
value={config.idColumn || ""}
|
||||
onValueChange={(value) => updateConfig("idColumn", value)}
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">부모 ID 컬럼</Label>
|
||||
<Select
|
||||
value={config.parentIdColumn || ""}
|
||||
onValueChange={(value) => updateConfig("parentIdColumn", value)}
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">표시 컬럼</Label>
|
||||
<Select
|
||||
value={config.labelColumn || ""}
|
||||
onValueChange={(value) => updateConfig("labelColumn", value)}
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingColumns ? "로딩 중..." : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 설정 */}
|
||||
{config.dataSource === "api" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">API 엔드포인트</Label>
|
||||
<Input
|
||||
value={config.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||
placeholder="/api/hierarchy"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">옵션</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">최대 레벨</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.maxLevel || ""}
|
||||
onChange={(e) => updateConfig("maxLevel", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="제한 없음"
|
||||
min="1"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="draggable"
|
||||
checked={config.draggable || false}
|
||||
onCheckedChange={(checked) => updateConfig("draggable", checked)}
|
||||
/>
|
||||
<label htmlFor="draggable" className="text-xs">드래그 앤 드롭</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="selectable"
|
||||
checked={config.selectable !== false}
|
||||
onCheckedChange={(checked) => updateConfig("selectable", checked)}
|
||||
/>
|
||||
<label htmlFor="selectable" className="text-xs">선택 가능</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="multiSelect"
|
||||
checked={config.multiSelect || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiSelect", checked)}
|
||||
/>
|
||||
<label htmlFor="multiSelect" className="text-xs">다중 선택</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showCheckbox"
|
||||
checked={config.showCheckbox || false}
|
||||
onCheckedChange={(checked) => updateConfig("showCheckbox", checked)}
|
||||
/>
|
||||
<label htmlFor="showCheckbox" className="text-xs">체크박스 표시</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="expandAll"
|
||||
checked={config.expandAll || false}
|
||||
onCheckedChange={(checked) => updateConfig("expandAll", checked)}
|
||||
/>
|
||||
<label htmlFor="expandAll" className="text-xs">기본 전체 펼침</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 전용 설정 */}
|
||||
{config.hierarchyType === "bom" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">BOM 설정</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showQuantity"
|
||||
checked={config.showQuantity !== false}
|
||||
onCheckedChange={(checked) => updateConfig("showQuantity", checked)}
|
||||
/>
|
||||
<label htmlFor="showQuantity" className="text-xs">수량 표시</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">수량 컬럼</Label>
|
||||
<Select
|
||||
value={config.quantityColumn || ""}
|
||||
onValueChange={(value) => updateConfig("quantityColumn", value)}
|
||||
disabled={loadingColumns || !config.tableName}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 연쇄 선택박스 전용 설정 */}
|
||||
{config.hierarchyType === "cascading" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">연쇄 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">부모 필드</Label>
|
||||
<Select
|
||||
value={config.parentField || ""}
|
||||
onValueChange={(value) => updateConfig("parentField", value)}
|
||||
disabled={loadingColumns || !config.tableName}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="clearOnParentChange"
|
||||
checked={config.clearOnParentChange !== false}
|
||||
onCheckedChange={(checked) => updateConfig("clearOnParentChange", checked)}
|
||||
/>
|
||||
<label htmlFor="clearOnParentChange" className="text-xs">부모 변경 시 값 초기화</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedHierarchyConfigPanel.displayName = "UnifiedHierarchyConfigPanel";
|
||||
|
||||
export default UnifiedHierarchyConfigPanel;
|
||||
|
|
@ -5,23 +5,99 @@
|
|||
* 통합 입력 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
import { getAvailableNumberingRules } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
|
||||
interface UnifiedInputConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
|
||||
}
|
||||
|
||||
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange }) => {
|
||||
export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
|
||||
const [parentMenus, setParentMenus] = useState<any[]>([]);
|
||||
const [loadingMenus, setLoadingMenus] = useState(false);
|
||||
|
||||
// 선택된 메뉴 OBJID
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
|
||||
return config.autoGeneration?.selectedMenuObjid || menuObjid;
|
||||
});
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
// 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만)
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setLoadingMenus(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/admin/menus");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const allMenus = response.data.data;
|
||||
|
||||
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
|
||||
const level2UserMenus = allMenus.filter((menu: any) =>
|
||||
menu.menu_type === '1' && menu.lev === 2
|
||||
);
|
||||
|
||||
setParentMenus(level2UserMenus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("부모 메뉴 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingMenus(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
|
||||
useEffect(() => {
|
||||
const loadRules = async () => {
|
||||
if (config.autoGeneration?.type !== "numbering_rule") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedMenuObjid) {
|
||||
setNumberingRules([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRules(true);
|
||||
try {
|
||||
const response = await getAvailableNumberingRules(selectedMenuObjid);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadRules();
|
||||
}, [selectedMenuObjid, config.autoGeneration?.type]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 입력 타입 */}
|
||||
|
|
@ -41,14 +117,50 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
|
|||
<SelectItem value="textarea">여러 줄 텍스트</SelectItem>
|
||||
<SelectItem value="slider">슬라이더</SelectItem>
|
||||
<SelectItem value="color">색상 선택</SelectItem>
|
||||
<SelectItem value="numbering">채번 (자동생성)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* 채번 타입 전용 설정 */}
|
||||
{config.inputType === "numbering" && (
|
||||
<div className="space-y-3">
|
||||
<Separator />
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs font-medium text-blue-800">채번 타입 안내</p>
|
||||
<p className="mt-1 text-[10px] text-blue-700">
|
||||
채번 규칙은 <strong>테이블 관리</strong>에서 컬럼별로 설정됩니다.
|
||||
<br />
|
||||
화면에 배치된 컬럼의 채번 규칙이 자동으로 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 형식 (텍스트/숫자용) */}
|
||||
{(config.inputType === "text" || !config.inputType) && (
|
||||
{/* 채번 필드는 기본적으로 읽기전용 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="numberingReadonly"
|
||||
checked={config.readonly !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateConfig("readonly", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="numberingReadonly" className="text-xs font-medium cursor-pointer">
|
||||
읽기전용 (권장)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px] pl-6">
|
||||
채번 필드는 자동으로 생성되므로 읽기전용을 권장합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 타입이 아닌 경우에만 추가 설정 표시 */}
|
||||
{config.inputType !== "numbering" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* 형식 (텍스트/숫자용) */}
|
||||
{(config.inputType === "text" || !config.inputType) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">입력 형식</Label>
|
||||
<Select value={config.format || "none"} onValueChange={(value) => updateConfig("format", value)}>
|
||||
|
|
@ -143,6 +255,231 @@ export const UnifiedInputConfigPanel: React.FC<UnifiedInputConfigPanelProps> = (
|
|||
/>
|
||||
<p className="text-muted-foreground text-[10px]"># = 숫자, A = 문자, * = 모든 문자</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 자동생성 기능 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="autoGenerationEnabled"
|
||||
checked={config.autoGeneration?.enabled || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
updateConfig("autoGeneration", {
|
||||
...currentConfig,
|
||||
enabled: checked as boolean,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="autoGenerationEnabled" className="text-xs font-medium cursor-pointer">
|
||||
자동생성 활성화
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 자동생성 타입 선택 */}
|
||||
{config.autoGeneration?.enabled && (
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동생성 타입</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.type || "none"}
|
||||
onValueChange={(value: AutoGenerationType) => {
|
||||
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||
updateConfig("autoGeneration", {
|
||||
...currentConfig,
|
||||
type: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="자동생성 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">자동생성 없음</SelectItem>
|
||||
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||
<SelectItem value="numbering_rule">채번 규칙</SelectItem>
|
||||
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||
<SelectItem value="department">부서 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 선택된 타입 설명 */}
|
||||
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{config.autoGeneration?.type === "numbering_rule" && (
|
||||
<>
|
||||
{/* 부모 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
대상 메뉴 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedMenuObjid?.toString() || ""}
|
||||
onValueChange={(value) => {
|
||||
const menuId = parseInt(value);
|
||||
setSelectedMenuObjid(menuId);
|
||||
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
selectedMenuObjid: menuId,
|
||||
});
|
||||
}}
|
||||
disabled={loadingMenus}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentMenus.length === 0 ? (
|
||||
<SelectItem value="no-menus" disabled>
|
||||
사용 가능한 메뉴가 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
parentMenus.map((menu) => (
|
||||
<SelectItem key={menu.objid} value={menu.objid.toString()}>
|
||||
{menu.menu_name_kor}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 채번 규칙 선택 */}
|
||||
{selectedMenuObjid ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">
|
||||
채번 규칙 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.autoGeneration?.options?.numberingRuleId || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
numberingRuleId: value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={loadingRules}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numberingRules.length === 0 ? (
|
||||
<SelectItem value="no-rules" disabled>
|
||||
사용 가능한 규칙이 없습니다
|
||||
</SelectItem>
|
||||
) : (
|
||||
numberingRules.map((rule) => (
|
||||
<SelectItem key={rule.ruleId} value={rule.ruleId}>
|
||||
{rule.ruleName}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
|
||||
먼저 대상 메뉴를 선택하세요
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 자동생성 옵션 (랜덤/순차용) */}
|
||||
{config.autoGeneration?.type &&
|
||||
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-2">
|
||||
{/* 길이 설정 */}
|
||||
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">길이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={config.autoGeneration?.options?.length || 8}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
length: parseInt(e.target.value) || 8,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 접두사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">접두사</Label>
|
||||
<Input
|
||||
value={config.autoGeneration?.options?.prefix || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
prefix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="예: INV-"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 접미사 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">접미사</Label>
|
||||
<Input
|
||||
value={config.autoGeneration?.options?.suffix || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("autoGeneration", {
|
||||
...config.autoGeneration,
|
||||
options: {
|
||||
...config.autoGeneration?.options,
|
||||
suffix: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">미리보기</Label>
|
||||
<div className="rounded border bg-muted p-2 text-xs font-mono">
|
||||
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedLayout 설정 패널
|
||||
* 통합 레이아웃 컴포넌트의 세부 설정을 관리합니다.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface UnifiedLayoutConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const UnifiedLayoutConfigPanel: React.FC<UnifiedLayoutConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// 설정 업데이트 핸들러
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 레이아웃 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">레이아웃 타입</Label>
|
||||
<Select
|
||||
value={config.layoutType || config.type || "grid"}
|
||||
onValueChange={(value) => updateConfig("layoutType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">그리드</SelectItem>
|
||||
<SelectItem value="split">분할 패널</SelectItem>
|
||||
<SelectItem value="flex">플렉스</SelectItem>
|
||||
<SelectItem value="divider">구분선</SelectItem>
|
||||
<SelectItem value="screen-embed">화면 임베드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 그리드 설정 */}
|
||||
{(config.layoutType === "grid" || !config.layoutType) && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">그리드 설정</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="use12Column"
|
||||
checked={config.use12Column !== false}
|
||||
onCheckedChange={(checked) => updateConfig("use12Column", checked)}
|
||||
/>
|
||||
<label htmlFor="use12Column" className="text-xs">12컬럼 그리드 시스템 사용</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼 수</Label>
|
||||
<Select
|
||||
value={String(config.columns || 12)}
|
||||
onValueChange={(value) => updateConfig("columns", Number(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="4">4</SelectItem>
|
||||
<SelectItem value="6">6</SelectItem>
|
||||
<SelectItem value="12">12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">간격 (px)</Label>
|
||||
<Input
|
||||
value={config.gap || "16"}
|
||||
onChange={(e) => updateConfig("gap", e.target.value)}
|
||||
placeholder="16"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널 설정 */}
|
||||
{config.layoutType === "split" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">분할 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">분할 방향</Label>
|
||||
<Select
|
||||
value={config.direction || "horizontal"}
|
||||
onValueChange={(value) => updateConfig("direction", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">비율 (%)</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={config.splitRatio?.[0] || 50}
|
||||
onChange={(e) => updateConfig("splitRatio", [Number(e.target.value), 100 - Number(e.target.value)])}
|
||||
placeholder="50"
|
||||
min="10"
|
||||
max="90"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.splitRatio?.[1] || 50}
|
||||
disabled
|
||||
className="h-8 text-xs bg-muted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="resizable"
|
||||
checked={config.resizable !== false}
|
||||
onCheckedChange={(checked) => updateConfig("resizable", checked)}
|
||||
/>
|
||||
<label htmlFor="resizable" className="text-xs">크기 조절 가능</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 플렉스 설정 */}
|
||||
{config.layoutType === "flex" && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">플렉스 설정</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">방향</Label>
|
||||
<Select
|
||||
value={config.direction || "row"}
|
||||
onValueChange={(value) => updateConfig("direction", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="row">가로</SelectItem>
|
||||
<SelectItem value="column">세로</SelectItem>
|
||||
<SelectItem value="row-reverse">가로 (역순)</SelectItem>
|
||||
<SelectItem value="column-reverse">세로 (역순)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">정렬</Label>
|
||||
<Select
|
||||
value={config.justifyContent || "flex-start"}
|
||||
onValueChange={(value) => updateConfig("justifyContent", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flex-start">시작</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="flex-end">끝</SelectItem>
|
||||
<SelectItem value="space-between">양끝 정렬</SelectItem>
|
||||
<SelectItem value="space-around">균등 배치</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">교차축 정렬</Label>
|
||||
<Select
|
||||
value={config.alignItems || "stretch"}
|
||||
onValueChange={(value) => updateConfig("alignItems", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="flex-start">시작</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="flex-end">끝</SelectItem>
|
||||
<SelectItem value="stretch">늘리기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">간격 (px)</Label>
|
||||
<Input
|
||||
value={config.gap || "16"}
|
||||
onChange={(e) => updateConfig("gap", e.target.value)}
|
||||
placeholder="16"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="wrap"
|
||||
checked={config.wrap || false}
|
||||
onCheckedChange={(checked) => updateConfig("wrap", checked)}
|
||||
/>
|
||||
<label htmlFor="wrap" className="text-xs">줄바꿈 허용</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 임베드 설정 */}
|
||||
{config.layoutType === "screen-embed" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">임베드할 화면 ID</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.screenId || ""}
|
||||
onChange={(e) => updateConfig("screenId", e.target.value ? Number(e.target.value) : undefined)}
|
||||
placeholder="화면 ID"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedLayoutConfigPanel.displayName = "UnifiedLayoutConfigPanel";
|
||||
|
||||
export default UnifiedLayoutConfigPanel;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedList 설정 패널
|
||||
* TableListConfigPanel을 래핑하여 동일한 설정 기능을 제공합니다.
|
||||
* 카드 표시는 별도의 card-display 컴포넌트를 사용합니다.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
|
||||
import { TableListConfig } from "@/lib/registry/components/table-list/types";
|
||||
|
||||
interface UnifiedListConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
/** 현재 화면의 테이블명 */
|
||||
currentTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UnifiedList 설정 패널
|
||||
* TableListConfigPanel과 동일한 기능을 제공
|
||||
*/
|
||||
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
currentTableName,
|
||||
}) => {
|
||||
// UnifiedList config를 TableListConfig 형식으로 변환
|
||||
const tableListConfig: TableListConfig = useMemo(() => {
|
||||
// 컬럼 형식 변환: UnifiedList columns -> TableList columns
|
||||
const columns = (config.columns || []).map((col: any, index: number) => ({
|
||||
columnName: col.key || col.columnName || col.field || "",
|
||||
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
|
||||
width: col.width ? parseInt(col.width, 10) : undefined,
|
||||
visible: col.visible !== false,
|
||||
sortable: col.sortable !== false,
|
||||
searchable: col.searchable !== false,
|
||||
align: col.align || "left",
|
||||
order: index,
|
||||
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
|
||||
thousandSeparator: col.thousandSeparator,
|
||||
editable: col.editable,
|
||||
entityDisplayConfig: col.entityDisplayConfig,
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
|
||||
tableName: config.tableName || config.dataSource?.table || currentTableName,
|
||||
columns,
|
||||
useCustomTable: config.useCustomTable,
|
||||
customTableName: config.customTableName,
|
||||
isReadOnly: config.isReadOnly !== false, // UnifiedList는 기본적으로 읽기 전용
|
||||
displayMode: "table", // 테이블 모드 고정 (카드는 card-display 컴포넌트 사용)
|
||||
pagination: config.pagination !== false ? {
|
||||
enabled: true,
|
||||
pageSize: config.pageSize || 10,
|
||||
position: "bottom",
|
||||
showPageSize: true,
|
||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
||||
} : {
|
||||
enabled: false,
|
||||
pageSize: 10,
|
||||
position: "bottom",
|
||||
showPageSize: false,
|
||||
pageSizeOptions: [10],
|
||||
},
|
||||
filter: config.filter,
|
||||
dataFilter: config.dataFilter,
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
position: "left",
|
||||
showHeader: true,
|
||||
},
|
||||
height: "auto",
|
||||
autoWidth: true,
|
||||
stickyHeader: true,
|
||||
autoLoad: true,
|
||||
horizontalScroll: {
|
||||
enabled: true,
|
||||
minColumnWidth: 100,
|
||||
maxColumnWidth: 300,
|
||||
},
|
||||
};
|
||||
}, [config, currentTableName]);
|
||||
|
||||
// TableListConfig 변경을 UnifiedList config 형식으로 변환
|
||||
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
|
||||
const newConfig: Record<string, any> = { ...config };
|
||||
|
||||
// 테이블 설정 변환
|
||||
if (partialConfig.selectedTable !== undefined) {
|
||||
newConfig.tableName = partialConfig.selectedTable;
|
||||
if (!newConfig.dataSource) {
|
||||
newConfig.dataSource = {};
|
||||
}
|
||||
newConfig.dataSource.table = partialConfig.selectedTable;
|
||||
}
|
||||
if (partialConfig.tableName !== undefined) {
|
||||
newConfig.tableName = partialConfig.tableName;
|
||||
if (!newConfig.dataSource) {
|
||||
newConfig.dataSource = {};
|
||||
}
|
||||
newConfig.dataSource.table = partialConfig.tableName;
|
||||
}
|
||||
if (partialConfig.useCustomTable !== undefined) {
|
||||
newConfig.useCustomTable = partialConfig.useCustomTable;
|
||||
}
|
||||
if (partialConfig.customTableName !== undefined) {
|
||||
newConfig.customTableName = partialConfig.customTableName;
|
||||
}
|
||||
if (partialConfig.isReadOnly !== undefined) {
|
||||
newConfig.isReadOnly = partialConfig.isReadOnly;
|
||||
}
|
||||
|
||||
// 컬럼 형식 변환: TableList columns -> UnifiedList columns
|
||||
if (partialConfig.columns !== undefined) {
|
||||
newConfig.columns = partialConfig.columns.map((col: any) => ({
|
||||
key: col.columnName,
|
||||
field: col.columnName,
|
||||
title: col.displayName,
|
||||
header: col.displayName,
|
||||
width: col.width ? String(col.width) : undefined,
|
||||
visible: col.visible,
|
||||
sortable: col.sortable,
|
||||
searchable: col.searchable,
|
||||
align: col.align,
|
||||
isJoinColumn: col.isEntityJoin,
|
||||
isEntityJoin: col.isEntityJoin,
|
||||
thousandSeparator: col.thousandSeparator,
|
||||
editable: col.editable,
|
||||
entityDisplayConfig: col.entityDisplayConfig,
|
||||
}));
|
||||
}
|
||||
|
||||
// 페이지네이션 변환
|
||||
if (partialConfig.pagination !== undefined) {
|
||||
newConfig.pagination = partialConfig.pagination?.enabled;
|
||||
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
|
||||
}
|
||||
|
||||
// 필터 변환
|
||||
if (partialConfig.filter !== undefined) {
|
||||
newConfig.filter = partialConfig.filter;
|
||||
}
|
||||
|
||||
// 데이터 필터 변환
|
||||
if (partialConfig.dataFilter !== undefined) {
|
||||
newConfig.dataFilter = partialConfig.dataFilter;
|
||||
}
|
||||
|
||||
console.log("⚙️ UnifiedListConfigPanel handleConfigChange:", { partialConfig, newConfig });
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableListConfigPanel
|
||||
config={tableListConfig}
|
||||
onChange={handleConfigChange}
|
||||
screenTableName={currentTableName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedListConfigPanel.displayName = "UnifiedListConfigPanel";
|
||||
|
||||
export default UnifiedListConfigPanel;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue