Compare commits
No commits in common. "main" and "feature/inbound-inventory-fix" have entirely different histories.
main
...
feature/in
|
|
@ -1,559 +0,0 @@
|
|||
# 다국어 지원 컴포넌트 개발 가이드
|
||||
|
||||
새로운 화면 컴포넌트를 개발할 때 반드시 다국어 시스템을 고려해야 합니다.
|
||||
이 가이드는 컴포넌트가 다국어 자동 생성 및 매핑 시스템과 호환되도록 하는 방법을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 타입 정의 시 다국어 필드 추가
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 함께 정의해야 합니다.
|
||||
|
||||
### 단일 텍스트 속성
|
||||
|
||||
```typescript
|
||||
interface MyComponentConfig {
|
||||
// 기본 텍스트
|
||||
title?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
|
||||
// 라벨
|
||||
label?: string;
|
||||
labelLangKeyId?: number;
|
||||
labelLangKey?: string;
|
||||
|
||||
// 플레이스홀더
|
||||
placeholder?: string;
|
||||
placeholderLangKeyId?: number;
|
||||
placeholderLangKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 배열/목록 속성 (컬럼, 탭 등)
|
||||
|
||||
```typescript
|
||||
interface ColumnConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 기타 속성
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
// 탭 제목도 별도로
|
||||
title?: string;
|
||||
titleLangKeyId?: number;
|
||||
titleLangKey?: string;
|
||||
}
|
||||
|
||||
interface MyComponentConfig {
|
||||
columns?: ColumnConfig[];
|
||||
tabs?: TabConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
### 버튼 컴포넌트
|
||||
|
||||
```typescript
|
||||
interface ButtonComponentConfig {
|
||||
text?: string;
|
||||
// 다국어 키 (필수 추가)
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 실제 예시: 분할 패널
|
||||
|
||||
```typescript
|
||||
interface SplitPanelLayoutConfig {
|
||||
leftPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 좌측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number; // 각 컬럼 다국어
|
||||
langKey?: string;
|
||||
}>;
|
||||
};
|
||||
rightPanel?: {
|
||||
title?: string;
|
||||
langKeyId?: number; // 우측 패널 제목 다국어
|
||||
langKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
additionalTabs?: Array<{
|
||||
label: string;
|
||||
langKeyId?: number; // 탭 라벨 다국어
|
||||
langKey?: string;
|
||||
title?: string;
|
||||
titleLangKeyId?: number; // 탭 제목 다국어
|
||||
titleLangKey?: string;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
langKeyId?: number;
|
||||
langKey?: string;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 라벨 추출 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractMultilangLabels` 함수에 추가
|
||||
|
||||
새 컴포넌트의 라벨을 추출하는 로직을 추가해야 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 타입 체크
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 추출
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,-
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 컬럼 추출
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col, index) => {
|
||||
const colLabel = col.label || col.name;
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: colLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 버튼 텍스트 추출 (버튼 컴포넌트인 경우)
|
||||
if (config?.text) {
|
||||
addLabel({
|
||||
id: `${comp.id}_button`,
|
||||
componentId: `${comp.id}_button`,
|
||||
label: config.text,
|
||||
type: "button",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.text,
|
||||
langKeyId: config.langKeyId,
|
||||
langKey: config.langKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 추출해야 할 라벨 타입
|
||||
|
||||
| 타입 | 설명 | 예시 |
|
||||
| ------------- | ------------------ | ------------------------ |
|
||||
| `title` | 컴포넌트/패널 제목 | 분할패널 제목, 카드 제목 |
|
||||
| `label` | 입력 필드 라벨 | 텍스트 입력 라벨 |
|
||||
| `button` | 버튼 텍스트 | 저장, 취소, 삭제 |
|
||||
| `column` | 테이블 컬럼 헤더 | 품목명, 수량, 금액 |
|
||||
| `tab` | 탭 라벨 | 기본정보, 상세정보 |
|
||||
| `filter` | 검색 필터 라벨 | 검색어, 기간 |
|
||||
| `placeholder` | 플레이스홀더 | "검색어를 입력하세요" |
|
||||
| `action` | 액션 버튼/링크 | 수정, 삭제, 상세보기 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 매핑 적용 로직 등록
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `applyMultilangMappings` 함수에 추가
|
||||
|
||||
다국어 키가 선택되면 컴포넌트에 `langKeyId`와 `langKey`를 저장하는 로직을 추가합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 매핑 적용
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 1. 제목 매핑
|
||||
const titleMapping = mappingMap.get(`${comp.id}_title`);
|
||||
if (titleMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
titleLangKeyId: titleMapping.keyId,
|
||||
titleLangKey: titleMapping.langKey,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 컬럼 매핑
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
const updatedColumns = config.columns.map((col, index) => {
|
||||
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
|
||||
if (colMapping) {
|
||||
return {
|
||||
...col,
|
||||
langKeyId: colMapping.keyId,
|
||||
langKey: colMapping.langKey,
|
||||
};
|
||||
}
|
||||
return col;
|
||||
});
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 버튼 매핑 (버튼 컴포넌트인 경우)
|
||||
const buttonMapping = mappingMap.get(`${comp.id}_button`);
|
||||
if (buttonMapping) {
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: buttonMapping.keyId,
|
||||
langKey: buttonMapping.langKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- **객체 참조 유지**: 매핑 시 기존 `updated.componentConfig`를 기반으로 업데이트해야 합니다.
|
||||
- **중첩 구조**: 중첩된 객체(예: `leftPanel.columns`)는 상위 객체부터 순서대로 업데이트합니다.
|
||||
|
||||
```typescript
|
||||
// 잘못된 방법 - 이전 업데이트 덮어쓰기
|
||||
updated.componentConfig = { ...config, langKeyId: mapping.keyId }; // ❌
|
||||
updated.componentConfig = { ...config, columns: updatedColumns }; // langKeyId 사라짐!
|
||||
|
||||
// 올바른 방법 - 이전 업데이트 유지
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
langKeyId: mapping.keyId,
|
||||
}; // ✅
|
||||
updated.componentConfig = {
|
||||
...updated.componentConfig,
|
||||
columns: updatedColumns,
|
||||
}; // ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 번역 표시 로직 구현
|
||||
|
||||
### 파일 위치
|
||||
|
||||
새 컴포넌트 파일 (예: `frontend/lib/registry/components/my-component/MyComponent.tsx`)
|
||||
|
||||
### Context 사용
|
||||
|
||||
```typescript
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
const MyComponent = ({ component }: Props) => {
|
||||
const { getTranslatedText } = useScreenMultiLang();
|
||||
const config = component.componentConfig;
|
||||
|
||||
// 제목 번역
|
||||
const displayTitle = config?.titleLangKey
|
||||
? getTranslatedText(config.titleLangKey, config.title || "")
|
||||
: config?.title || "";
|
||||
|
||||
// 컬럼 헤더 번역
|
||||
const translatedColumns = config?.columns?.map((col) => ({
|
||||
...col,
|
||||
displayLabel: col.langKey
|
||||
? getTranslatedText(col.langKey, col.label)
|
||||
: col.label,
|
||||
}));
|
||||
|
||||
// 버튼 텍스트 번역
|
||||
const buttonText = config?.langKey
|
||||
? getTranslatedText(config.langKey, config.text || "")
|
||||
: config?.text || "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>{displayTitle}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{translatedColumns?.map((col, idx) => (
|
||||
<th key={idx}>{col.displayLabel}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<button>{buttonText}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### getTranslatedText 함수
|
||||
|
||||
```typescript
|
||||
// 첫 번째 인자: langKey (다국어 키)
|
||||
// 두 번째 인자: fallback (키가 없거나 번역이 없을 때 기본값)
|
||||
const text = getTranslatedText(
|
||||
"screen.company_1.Sales.OrderList.품목명",
|
||||
"품목명"
|
||||
);
|
||||
```
|
||||
|
||||
### 주의사항
|
||||
|
||||
- `langKey`가 없으면 원본 텍스트를 표시합니다.
|
||||
- `useScreenMultiLang`은 반드시 `ScreenMultiLangProvider` 내부에서 사용해야 합니다.
|
||||
- 화면 페이지(`/screens/[screenId]/page.tsx`)에서 이미 Provider로 감싸져 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. ScreenMultiLangContext에 키 수집 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/contexts/ScreenMultiLangContext.tsx`
|
||||
|
||||
### `collectLangKeys` 함수에 추가
|
||||
|
||||
번역을 미리 로드하기 위해 컴포넌트에서 사용하는 모든 `langKey`를 수집해야 합니다.
|
||||
|
||||
```typescript
|
||||
const collectLangKeys = (comps: ComponentData[]): Set<string> => {
|
||||
const keys = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 langKey 수집
|
||||
if (comp.componentType === "my-new-component") {
|
||||
// 제목
|
||||
if (config?.titleLangKey) {
|
||||
keys.add(config.titleLangKey);
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns && Array.isArray(config.columns)) {
|
||||
config.columns.forEach((col: any) => {
|
||||
if (col.langKey) {
|
||||
keys.add(col.langKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 버튼
|
||||
if (config?.langKey) {
|
||||
keys.add(config.langKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return keys;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. MultilangSettingsModal에 표시 로직 추가
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/components/screen/modals/MultilangSettingsModal.tsx`
|
||||
|
||||
### `extractLabelsFromComponents` 함수에 추가
|
||||
|
||||
다국어 설정 모달에서 새 컴포넌트의 라벨이 표시되도록 합니다.
|
||||
|
||||
```typescript
|
||||
// 새 컴포넌트 라벨 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
const config = comp.componentConfig as MyComponentConfig;
|
||||
|
||||
// 제목
|
||||
if (config?.title) {
|
||||
addLabel({
|
||||
id: `${comp.id}_title`,
|
||||
componentId: `${comp.id}_title`,
|
||||
label: config.title,
|
||||
type: "title",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title,
|
||||
langKeyId: config.titleLangKeyId,
|
||||
langKey: config.titleLangKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 컬럼
|
||||
if (config?.columns) {
|
||||
config.columns.forEach((col, index) => {
|
||||
// columnLabelMap에서 라벨 가져오기 (테이블 컬럼인 경우)
|
||||
const tableName = config.tableName;
|
||||
const displayLabel =
|
||||
tableName && columnLabelMap[tableName]?.[col.name]
|
||||
? columnLabelMap[tableName][col.name]
|
||||
: col.label || col.name;
|
||||
|
||||
addLabel({
|
||||
id: `${comp.id}_col_${index}`,
|
||||
componentId: `${comp.id}_col_${index}`,
|
||||
label: displayLabel,
|
||||
type: "column",
|
||||
parentType: "my-new-component",
|
||||
parentLabel: config.title || "새 컴포넌트",
|
||||
langKeyId: col.langKeyId,
|
||||
langKey: col.langKey,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 테이블명 추출 (테이블 사용 컴포넌트인 경우)
|
||||
|
||||
### 파일 위치
|
||||
|
||||
`frontend/lib/utils/multilangLabelExtractor.ts`
|
||||
|
||||
### `extractTableNames` 함수에 추가
|
||||
|
||||
컴포넌트가 테이블을 사용하는 경우, 테이블명을 추출해야 컬럼 라벨을 가져올 수 있습니다.
|
||||
|
||||
```typescript
|
||||
const extractTableNames = (comps: ComponentData[]): Set<string> => {
|
||||
const tableNames = new Set<string>();
|
||||
|
||||
const processComponent = (comp: ComponentData) => {
|
||||
const config = comp.componentConfig;
|
||||
|
||||
// 새 컴포넌트의 테이블명 추출
|
||||
if (comp.componentType === "my-new-component") {
|
||||
if (config?.tableName) {
|
||||
tableNames.add(config.tableName);
|
||||
}
|
||||
if (config?.selectedTable) {
|
||||
tableNames.add(config.selectedTable);
|
||||
}
|
||||
}
|
||||
|
||||
// 자식 컴포넌트 재귀 처리
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
comp.children.forEach(processComponent);
|
||||
}
|
||||
};
|
||||
|
||||
comps.forEach(processComponent);
|
||||
return tableNames;
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 체크리스트
|
||||
|
||||
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- [ ] 모든 텍스트 속성에 `langKeyId`, `langKey` 필드 추가
|
||||
- [ ] 배열 속성(columns, tabs 등)의 각 항목에도 다국어 필드 추가
|
||||
|
||||
### 라벨 추출 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
|
||||
- [ ] `extractTableNames` 함수에 테이블명 추출 로직 추가 (해당되는 경우)
|
||||
|
||||
### 매핑 적용 (multilangLabelExtractor.ts)
|
||||
|
||||
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
|
||||
|
||||
### 번역 표시 (컴포넌트 파일)
|
||||
|
||||
- [ ] `useScreenMultiLang` 훅 사용
|
||||
- [ ] `getTranslatedText`로 텍스트 번역 적용
|
||||
|
||||
### 키 수집 (ScreenMultiLangContext.tsx)
|
||||
|
||||
- [ ] `collectLangKeys` 함수에 langKey 수집 로직 추가
|
||||
|
||||
### 설정 모달 (MultilangSettingsModal.tsx)
|
||||
|
||||
- [ ] `extractLabelsFromComponents`에 라벨 표시 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일 목록
|
||||
|
||||
| 파일 | 역할 |
|
||||
| -------------------------------------------------------------- | ----------------------- |
|
||||
| `frontend/lib/utils/multilangLabelExtractor.ts` | 라벨 추출 및 매핑 적용 |
|
||||
| `frontend/contexts/ScreenMultiLangContext.tsx` | 번역 Context 및 키 수집 |
|
||||
| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 설정 UI |
|
||||
| `frontend/components/screen/ScreenDesigner.tsx` | 다국어 생성 버튼 처리 |
|
||||
| `backend-node/src/services/multilangService.ts` | 다국어 키 생성 서비스 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 주의사항
|
||||
|
||||
1. **componentId 형식 일관성**: 라벨 추출과 매핑 적용에서 동일한 ID 형식 사용
|
||||
|
||||
- 제목: `${comp.id}_title`
|
||||
- 컬럼: `${comp.id}_col_${index}`
|
||||
- 버튼: `${comp.id}_button`
|
||||
|
||||
2. **중첩 구조 주의**: 분할패널처럼 중첩된 구조는 경로를 명확히 지정
|
||||
|
||||
- `${comp.id}_left_title`, `${comp.id}_right_col_${index}`
|
||||
|
||||
3. **기존 값 보존**: 매핑 적용 시 `updated.componentConfig`를 기반으로 업데이트
|
||||
|
||||
4. **라벨 타입 구분**: 입력 폼의 `label`과 다른 컴포넌트의 `label`을 구분하여 처리
|
||||
|
||||
5. **테스트**: 다국어 생성 → 다국어 설정 → 언어 변경 순서로 테스트
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
70
PLAN.MD
70
PLAN.MD
|
|
@ -1,72 +1,4 @@
|
|||
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
|
||||
|
||||
## 개요
|
||||
화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
### 1. 단일 화면 복제
|
||||
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
||||
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
||||
- [x] 연결된 모달 화면 함께 복제
|
||||
- [x] 대상 그룹 선택 가능
|
||||
- [x] 복제 후 목록 자동 새로고침
|
||||
|
||||
### 2. 그룹(폴더) 전체 복제
|
||||
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
||||
- [x] 정렬 순서(display_order) 유지
|
||||
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
||||
- [x] 정렬 순서 입력 필드 추가
|
||||
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
|
||||
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
|
||||
|
||||
### 3. 고급 옵션: 이름 일괄 변경
|
||||
- [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
|
||||
- [x] 미리보기 기능
|
||||
|
||||
### 4. 삭제 기능
|
||||
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
||||
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
|
||||
- [x] 삭제 시 로딩 프로그레스 바 표시
|
||||
|
||||
### 5. 화면 수정 기능
|
||||
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
|
||||
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
|
||||
|
||||
### 6. 테이블 설정 기능 (TableSettingModal)
|
||||
- [x] 화면 설정 모달에 "테이블 설정" 탭 추가
|
||||
- [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
|
||||
- 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
|
||||
- 코드→다른 타입: codeCategory, codeValue 초기화
|
||||
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
|
||||
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
|
||||
|
||||
### 7. 회사 코드 지원 (최고 관리자)
|
||||
- [x] 대상 회사 선택 가능
|
||||
- [x] 상위 그룹 선택 시 자동 회사 코드 설정
|
||||
|
||||
## 관련 파일
|
||||
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
|
||||
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||
- `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
|
||||
- `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
|
||||
- `frontend/lib/api/screen.ts` - 화면 API
|
||||
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
||||
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
|
||||
|
||||
## 진행 상태
|
||||
- [완료] 단일 화면 복제 + 새로고침
|
||||
- [완료] 그룹 전체 복제 (재귀적)
|
||||
- [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
|
||||
- [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
|
||||
- [완료] 화면 수정 (이름/그룹/역할/순서)
|
||||
- [완료] 테이블 설정 탭 추가
|
||||
- [완료] 입력 타입 변경 시 관련 필드 초기화
|
||||
- [완료] 그룹 복제 모달 스크롤 문제 수정
|
||||
|
||||
---
|
||||
|
||||
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
@ -1044,7 +1043,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 +2370,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",
|
||||
|
|
@ -3217,16 +3214,6 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bwip-js": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/bwip-js/-/bwip-js-3.2.3.tgz",
|
||||
"integrity": "sha512-kgL1GOW7n5FhlC5aXnckaEim0rz1cFM4t9/xUwuNXCIDnWLx8ruQ4JQkG6znq4GQFovNLhQy5JdgbDwJw4D/zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
|
|
@ -3476,7 +3463,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 +3699,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 +3916,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4458,7 +4442,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -5669,7 +5652,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 +7414,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 +8383,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 +9272,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 +10122,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 +10931,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 +11036,6 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bwip-js": "^3.2.3",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
|||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
|
|
@ -73,7 +72,6 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
|||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
|
|
@ -198,7 +196,6 @@ app.use("/api/multilang", multilangRoutes);
|
|||
app.use("/api/table-management", tableManagementRoutes);
|
||||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
@ -223,7 +220,6 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
|||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음
|
||||
|
|
|
|||
|
|
@ -553,24 +553,10 @@ export const setUserLocale = async (
|
|||
|
||||
const { locale } = req.body;
|
||||
|
||||
if (!locale) {
|
||||
if (!locale || !["ko", "en", "ja", "zh"].includes(locale)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "로케일이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||
const validLang = await queryOne<{ lang_code: string }>(
|
||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||
[locale]
|
||||
);
|
||||
|
||||
if (!validLang) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 로케일입니다: ${locale}`,
|
||||
message: "유효하지 않은 로케일입니다. (ko, en, ja, zh 중 선택)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -1179,33 +1165,6 @@ export async function saveMenu(
|
|||
|
||||
logger.info("메뉴 저장 성공", { savedMenu });
|
||||
|
||||
// 다국어 메뉴 카테고리 자동 생성
|
||||
try {
|
||||
const { MultiLangService } = await import("../services/multilangService");
|
||||
const multilangService = new MultiLangService();
|
||||
|
||||
// 회사명 조회
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||
|
||||
// 메뉴 경로 조회 및 카테고리 생성
|
||||
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
|
||||
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
|
||||
|
||||
logger.info("메뉴 다국어 카테고리 생성 완료", {
|
||||
menuObjId: savedMenu.objid.toString(),
|
||||
menuPath,
|
||||
});
|
||||
} catch (categoryError) {
|
||||
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
|
||||
menuObjId: savedMenu.objid.toString(),
|
||||
error: categoryError,
|
||||
});
|
||||
}
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "메뉴가 성공적으로 저장되었습니다.",
|
||||
|
|
@ -1417,75 +1376,6 @@ export async function updateMenu(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수
|
||||
*/
|
||||
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
||||
const allIds: number[] = [];
|
||||
|
||||
// 직접 자식 메뉴들 조회
|
||||
const children = await query<any>(
|
||||
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
|
||||
[parentObjid]
|
||||
);
|
||||
|
||||
for (const child of children) {
|
||||
allIds.push(child.objid);
|
||||
// 자식의 자식들도 재귀적으로 수집
|
||||
const grandChildren = await collectAllChildMenuIds(child.objid);
|
||||
allIds.push(...grandChildren);
|
||||
}
|
||||
|
||||
return allIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||
*/
|
||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||
await query(
|
||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||
await query(
|
||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 7. screen_groups에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 삭제
|
||||
*/
|
||||
|
|
@ -1512,7 +1402,7 @@ export async function deleteMenu(
|
|||
|
||||
// 삭제하려는 메뉴 조회
|
||||
const currentMenu = await queryOne<any>(
|
||||
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
|
|
@ -1547,50 +1437,67 @@ export async function deleteMenu(
|
|||
}
|
||||
}
|
||||
|
||||
// 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리
|
||||
const menuObjid = Number(menuId);
|
||||
|
||||
// 하위 메뉴들 재귀적으로 수집
|
||||
const childMenuIds = await collectAllChildMenuIds(menuObjid);
|
||||
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}개`, {
|
||||
menuName: currentMenu.menu_name_kor,
|
||||
totalCount: allMenuIdsToDelete.length,
|
||||
childMenuIds,
|
||||
});
|
||||
|
||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
||||
for (const objid of allMenuIdsToDelete) {
|
||||
await cleanupMenuRelatedData(objid);
|
||||
}
|
||||
|
||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
||||
menuObjid,
|
||||
totalCleaned: allMenuIdsToDelete.length
|
||||
});
|
||||
|
||||
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
|
||||
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
|
||||
const reversedIds = [...allMenuIdsToDelete].reverse();
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
for (const objid of reversedIds) {
|
||||
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
|
||||
}
|
||||
// 3. code_info에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 4. numbering_rules에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 5. rel_menu_auth에서 관련 권한 삭제
|
||||
await query(
|
||||
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 6. screen_menu_assignments에서 관련 할당 삭제
|
||||
await query(
|
||||
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 삭제 성공", {
|
||||
deletedMenuObjid: menuObjid,
|
||||
deletedMenuName: currentMenu.menu_name_kor,
|
||||
totalDeleted: allMenuIdsToDelete.length,
|
||||
});
|
||||
logger.info("메뉴 관련 데이터 정리 완료", { menuObjid });
|
||||
|
||||
// Raw Query를 사용한 메뉴 삭제
|
||||
const [deletedMenu] = await query<any>(
|
||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
logger.info("메뉴 삭제 성공", { deletedMenu });
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
|
||||
message: "메뉴가 성공적으로 삭제되었습니다.",
|
||||
data: {
|
||||
objid: menuObjid.toString(),
|
||||
menuNameKor: currentMenu.menu_name_kor,
|
||||
deletedCount: allMenuIdsToDelete.length,
|
||||
deletedChildCount: childMenuIds.length,
|
||||
objid: deletedMenu.objid.toString(),
|
||||
menuNameKor: deletedMenu.menu_name_kor,
|
||||
menuNameEng: deletedMenu.menu_name_eng,
|
||||
menuUrl: deletedMenu.menu_url,
|
||||
menuDesc: deletedMenu.menu_desc,
|
||||
status: deletedMenu.status,
|
||||
writer: deletedMenu.writer,
|
||||
regdate: new Date(deletedMenu.regdate).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -1675,49 +1582,18 @@ export async function deleteMenusBatch(
|
|||
}
|
||||
}
|
||||
|
||||
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
|
||||
const allMenuIdsToDelete = new Set<number>();
|
||||
|
||||
for (const menuId of menuIds) {
|
||||
const objid = Number(menuId);
|
||||
allMenuIdsToDelete.add(objid);
|
||||
|
||||
// 하위 메뉴들 재귀적으로 수집
|
||||
const childMenuIds = await collectAllChildMenuIds(objid);
|
||||
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
|
||||
}
|
||||
|
||||
const allIdsArray = Array.from(allMenuIdsToDelete);
|
||||
|
||||
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, {
|
||||
selectedMenuIds: menuIds,
|
||||
totalWithChildren: allIdsArray.length,
|
||||
});
|
||||
|
||||
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
|
||||
for (const objid of allIdsArray) {
|
||||
await cleanupMenuRelatedData(objid);
|
||||
}
|
||||
|
||||
logger.info("메뉴 관련 데이터 정리 완료", {
|
||||
totalCleaned: allIdsArray.length
|
||||
});
|
||||
|
||||
// Raw Query를 사용한 메뉴 일괄 삭제
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
const deletedMenus: any[] = [];
|
||||
const failedMenuIds: string[] = [];
|
||||
|
||||
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
|
||||
const reversedIds = [...allIdsArray].reverse();
|
||||
|
||||
// 각 메뉴 ID에 대해 삭제 시도
|
||||
for (const menuObjid of reversedIds) {
|
||||
for (const menuId of menuIds) {
|
||||
try {
|
||||
const result = await query<any>(
|
||||
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
|
||||
[menuObjid]
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
|
|
@ -1728,20 +1604,20 @@ export async function deleteMenusBatch(
|
|||
});
|
||||
} else {
|
||||
failedCount++;
|
||||
failedMenuIds.push(String(menuObjid));
|
||||
failedMenuIds.push(menuId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
|
||||
logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error);
|
||||
failedCount++;
|
||||
failedMenuIds.push(String(menuObjid));
|
||||
failedMenuIds.push(menuId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("메뉴 일괄 삭제 완료", {
|
||||
requested: menuIds.length,
|
||||
totalWithChildren: allIdsArray.length,
|
||||
total: menuIds.length,
|
||||
deletedCount,
|
||||
failedCount,
|
||||
deletedMenus,
|
||||
failedMenuIds,
|
||||
});
|
||||
|
||||
|
|
@ -2773,24 +2649,6 @@ export const createCompany = async (
|
|||
});
|
||||
}
|
||||
|
||||
// 다국어 카테고리 자동 생성
|
||||
try {
|
||||
const { MultiLangService } = await import("../services/multilangService");
|
||||
const multilangService = new MultiLangService();
|
||||
await multilangService.ensureCompanyCategory(
|
||||
createdCompany.company_code,
|
||||
createdCompany.company_name
|
||||
);
|
||||
logger.info("회사 다국어 카테고리 생성 완료", {
|
||||
companyCode: createdCompany.company_code,
|
||||
});
|
||||
} catch (categoryError) {
|
||||
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
|
||||
companyCode: createdCompany.company_code,
|
||||
error: categoryError,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("회사 등록 성공", {
|
||||
companyCode: createdCompany.company_code,
|
||||
companyName: createdCompany.company_name,
|
||||
|
|
@ -3200,23 +3058,6 @@ export const updateProfile = async (
|
|||
}
|
||||
|
||||
if (locale !== undefined) {
|
||||
// language_master 테이블에서 유효한 언어 코드인지 확인
|
||||
const validLang = await queryOne<{ lang_code: string }>(
|
||||
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
|
||||
[locale]
|
||||
);
|
||||
|
||||
if (!validLang) {
|
||||
res.status(400).json({
|
||||
result: false,
|
||||
error: {
|
||||
code: "INVALID_LOCALE",
|
||||
details: `유효하지 않은 로케일입니다: ${locale}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateFields.push(`locale = $${paramIndex}`);
|
||||
updateValues.push(locale);
|
||||
paramIndex++;
|
||||
|
|
|
|||
|
|
@ -282,175 +282,3 @@ export async function previewCodeMerge(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||
*/
|
||||
export async function mergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 시작", {
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||
[oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리
|
||||
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedData.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 완료", {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedData.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedData: affectedData.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
rowsUpdated: parseInt(row.out_rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 미리보기
|
||||
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||
*/
|
||||
export async function previewMergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||
[oldValue, companyCode]
|
||||
);
|
||||
|
||||
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = preview.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
oldValue,
|
||||
preview: preview.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
affectedRows: parseInt(row.out_affected_rows),
|
||||
})),
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ export const deleteFormData = async (
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, screenId } = req.body;
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -240,16 +240,7 @@ export const deleteFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
// screenId를 숫자로 변환 (문자열로 전달될 수 있음)
|
||||
const parsedScreenId = screenId ? parseInt(screenId, 10) : undefined;
|
||||
|
||||
await dynamicFormService.deleteFormData(
|
||||
id,
|
||||
tableName,
|
||||
companyCode,
|
||||
userId,
|
||||
parsedScreenId // screenId 추가 (제어관리 실행용)
|
||||
);
|
||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -66,23 +66,11 @@ export class EntityJoinController {
|
|||
const userField = parsedAutoFilter.userField || "companyCode";
|
||||
const userValue = ((req as any).user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (parsedAutoFilter.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = parsedAutoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: parsedAutoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
searchConditions[filterColumn] = finalCompanyCode;
|
||||
if (userValue) {
|
||||
searchConditions[filterColumn] = userValue;
|
||||
logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", {
|
||||
filterColumn,
|
||||
finalCompanyCode,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import excelMappingService from "../services/excelMappingService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* POST /api/excel-mapping/find
|
||||
*/
|
||||
export async function findMappingByColumns(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 excelColumns(배열)가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.findMappingByColumns(
|
||||
tableName,
|
||||
excelColumns,
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (template) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "기존 매핑 템플릿을 찾았습니다.",
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: "일치하는 매핑 템플릿이 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* POST /api/excel-mapping/save
|
||||
*/
|
||||
export async function saveMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, excelColumns, columnMappings } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!tableName || !excelColumns || !columnMappings) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, excelColumns, columnMappings가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 요청", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId,
|
||||
});
|
||||
|
||||
const template = await excelMappingService.saveMappingTemplate(
|
||||
tableName,
|
||||
excelColumns,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: "매핑 템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 저장 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 매핑 템플릿 목록 조회
|
||||
* GET /api/excel-mapping/list/:tableName
|
||||
*/
|
||||
export async function getMappingTemplates(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 목록 조회 요청", {
|
||||
tableName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const templates = await excelMappingService.getMappingTemplates(
|
||||
tableName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
* DELETE /api/excel-mapping/:id
|
||||
*/
|
||||
export async function deleteMappingTemplate(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "id가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 삭제 요청", {
|
||||
id,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const deleted = await excelMappingService.deleteMappingTemplate(
|
||||
parseInt(id),
|
||||
companyCode
|
||||
);
|
||||
|
||||
if (deleted) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "매핑 템플릿이 삭제되었습니다.",
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -10,10 +10,7 @@ import {
|
|||
SaveLangTextsRequest,
|
||||
GetUserTextParams,
|
||||
BatchTranslationRequest,
|
||||
GenerateKeyRequest,
|
||||
CreateOverrideKeyRequest,
|
||||
ApiResponse,
|
||||
LangCategory,
|
||||
} from "../types/multilang";
|
||||
|
||||
/**
|
||||
|
|
@ -190,7 +187,7 @@ export const getLangKeys = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const { companyCode, menuCode, keyType, searchText } = req.query;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
|
|
@ -202,7 +199,6 @@ export const getLangKeys = async (
|
|||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -634,391 +630,6 @@ export const deleteLanguage = async (
|
|||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 카테고리 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories
|
||||
* 카테고리 목록 조회 API (트리 구조)
|
||||
*/
|
||||
export const getCategories = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("카테고리 목록 조회 요청", { user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const categories = await multiLangService.getCategories();
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 목록 조회 성공",
|
||||
data: categories,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId
|
||||
* 카테고리 상세 조회 API
|
||||
*/
|
||||
export const getCategoryById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 상세 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const category = await multiLangService.getCategoryById(parseInt(categoryId));
|
||||
|
||||
if (!category) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "카테고리를 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_NOT_FOUND",
|
||||
details: `Category ID ${categoryId} not found`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse<LangCategory> = {
|
||||
success: true,
|
||||
message: "카테고리 상세 조회 성공",
|
||||
data: category,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 상세 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 상세 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_DETAIL_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/categories/:categoryId/path
|
||||
* 카테고리 경로 조회 API (부모 포함)
|
||||
*/
|
||||
export const getCategoryPath = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId } = req.params;
|
||||
logger.info("카테고리 경로 조회 요청", { categoryId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const path = await multiLangService.getCategoryPath(parseInt(categoryId));
|
||||
|
||||
const response: ApiResponse<LangCategory[]> = {
|
||||
success: true,
|
||||
message: "카테고리 경로 조회 성공",
|
||||
data: path,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("카테고리 경로 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 경로 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CATEGORY_PATH_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 자동 생성 및 오버라이드 관련 API
|
||||
// =====================================================
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/generate
|
||||
* 키 자동 생성 API
|
||||
*/
|
||||
export const generateKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const generateData: GenerateKeyRequest = req.body;
|
||||
logger.info("키 자동 생성 요청", { generateData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!generateData.companyCode || !generateData.categoryId || !generateData.keyMeaning) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드, 카테고리 ID, 키 의미는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode, categoryId, and keyMeaning are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (generateData.companyCode === "*" && req.user?.companyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Only super admin can create common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 키만 생성 가능
|
||||
if (generateData.companyCode !== "*" &&
|
||||
req.user?.companyCode !== "*" &&
|
||||
generateData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.generateKey({
|
||||
...generateData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 자동 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 자동 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_GENERATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/preview
|
||||
* 키 미리보기 API
|
||||
*/
|
||||
export const previewKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { categoryId, keyMeaning, companyCode } = req.body;
|
||||
logger.info("키 미리보기 요청", { categoryId, keyMeaning, companyCode, user: req.user });
|
||||
|
||||
if (!categoryId || !keyMeaning || !companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "카테고리 ID, 키 의미, 회사 코드는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "categoryId, keyMeaning, and companyCode are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const preview = await multiLangService.previewGeneratedKey(
|
||||
parseInt(categoryId),
|
||||
keyMeaning,
|
||||
companyCode
|
||||
);
|
||||
|
||||
const response: ApiResponse<{
|
||||
langKey: string;
|
||||
exists: boolean;
|
||||
isOverride: boolean;
|
||||
baseKeyId?: number;
|
||||
}> = {
|
||||
success: true,
|
||||
message: "키 미리보기 성공",
|
||||
data: preview,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("키 미리보기 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "키 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "KEY_PREVIEW_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/keys/override
|
||||
* 오버라이드 키 생성 API
|
||||
*/
|
||||
export const createOverrideKey = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const overrideData: CreateOverrideKeyRequest = req.body;
|
||||
logger.info("오버라이드 키 생성 요청", { overrideData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
if (!overrideData.companyCode || !overrideData.baseKeyId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "회사 코드와 원본 키 ID는 필수입니다.",
|
||||
error: {
|
||||
code: "MISSING_REQUIRED_FIELDS",
|
||||
details: "companyCode and baseKeyId are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 최고 관리자(*)는 오버라이드 키를 만들 수 없음 (이미 공통 키)
|
||||
if (overrideData.companyCode === "*") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "공통 키에 대한 오버라이드는 생성할 수 없습니다.",
|
||||
error: {
|
||||
code: "INVALID_OVERRIDE",
|
||||
details: "Cannot create override for common keys",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사 관리자는 자기 회사 오버라이드만 생성 가능
|
||||
if (req.user?.companyCode !== "*" &&
|
||||
overrideData.companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 생성할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot create override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createOverrideKey({
|
||||
...overrideData,
|
||||
createdBy: req.user?.userId || "system",
|
||||
});
|
||||
|
||||
const response: ApiResponse<number> = {
|
||||
success: true,
|
||||
message: "오버라이드 키가 성공적으로 생성되었습니다.",
|
||||
data: keyId,
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEY_CREATE_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/multilang/keys/overrides/:companyCode
|
||||
* 회사별 오버라이드 키 목록 조회 API
|
||||
*/
|
||||
export const getOverrideKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode } = req.params;
|
||||
logger.info("오버라이드 키 목록 조회 요청", { companyCode, user: req.user });
|
||||
|
||||
// 권한 검사: 최고 관리자 또는 해당 회사 관리자만 조회 가능
|
||||
if (req.user?.companyCode !== "*" && companyCode !== req.user?.companyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 오버라이드 키를 조회할 권한이 없습니다.",
|
||||
error: {
|
||||
code: "PERMISSION_DENIED",
|
||||
details: "Cannot view override keys for other companies",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keys = await multiLangService.getOverrideKeys(companyCode);
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "오버라이드 키 목록 조회 성공",
|
||||
data: keys,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("오버라이드 키 목록 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "오버라이드 키 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "OVERRIDE_KEYS_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/batch
|
||||
* 다국어 텍스트 배치 조회 API
|
||||
|
|
@ -1099,86 +710,3 @@ export const getBatchTranslations = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/multilang/screen-labels
|
||||
* 화면 라벨 다국어 키 자동 생성 API
|
||||
*/
|
||||
export const generateScreenLabelKeys = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { screenId, menuObjId, labels } = req.body;
|
||||
|
||||
logger.info("화면 라벨 다국어 키 생성 요청", {
|
||||
screenId,
|
||||
menuObjId,
|
||||
labelCount: labels?.length,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!screenId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId는 필수입니다.",
|
||||
error: { code: "MISSING_SCREEN_ID" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!labels || !Array.isArray(labels) || labels.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "labels 배열이 필요합니다.",
|
||||
error: { code: "MISSING_LABELS" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면의 회사 정보 조회 (사용자 회사가 아닌 화면 소속 회사 기준)
|
||||
const { queryOne } = await import("../database/db");
|
||||
const screenInfo = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1`,
|
||||
[screenId]
|
||||
);
|
||||
const companyCode = screenInfo?.company_code || req.user?.companyCode || "*";
|
||||
|
||||
// 회사명 조회
|
||||
const companyInfo = await queryOne<{ company_name: string }>(
|
||||
`SELECT company_name FROM company_mng WHERE company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
|
||||
|
||||
logger.info("화면 소속 회사 정보", { screenId, companyCode, companyName });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const results = await multiLangService.generateScreenLabelKeys({
|
||||
screenId: Number(screenId),
|
||||
companyCode,
|
||||
companyName,
|
||||
menuObjId,
|
||||
labels,
|
||||
});
|
||||
|
||||
const response: ApiResponse<typeof results> = {
|
||||
success: true,
|
||||
message: `${results.length}개의 다국어 키가 생성되었습니다.`,
|
||||
data: results,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("화면 라벨 다국어 키 생성 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 라벨 다국어 키 생성 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SCREEN_LABEL_KEY_GENERATION_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -217,14 +217,11 @@ router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedReq
|
|||
const companyCode = req.user!.companyCode;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
logger.info("코드 할당 요청", { ruleId, companyCode });
|
||||
|
||||
try {
|
||||
const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
logger.info("코드 할당 성공", { ruleId, allocatedCode });
|
||||
return res.json({ success: true, data: { generatedCode: allocatedCode } });
|
||||
} catch (error: any) {
|
||||
logger.error("코드 할당 실패", { ruleId, companyCode, error: error.message });
|
||||
logger.error("코드 할당 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -775,25 +775,18 @@ export async function getTableData(
|
|||
const userField = autoFilter?.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
|
||||
let finalCompanyCode = userValue;
|
||||
if (autoFilter?.companyCodeOverride && userValue === "*") {
|
||||
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
|
||||
finalCompanyCode = autoFilter.companyCodeOverride;
|
||||
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
|
||||
originalCompanyCode: userValue,
|
||||
overrideCompanyCode: autoFilter.companyCodeOverride,
|
||||
tableName,
|
||||
});
|
||||
}
|
||||
|
||||
if (finalCompanyCode) {
|
||||
enhancedSearch[filterColumn] = finalCompanyCode;
|
||||
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
|
||||
if (userValue && userValue !== "*") {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
filterColumn,
|
||||
userField,
|
||||
userValue: finalCompanyCode,
|
||||
userValue,
|
||||
tableName,
|
||||
});
|
||||
} else if (userValue === "*") {
|
||||
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
|
||||
tableName,
|
||||
});
|
||||
} else {
|
||||
|
|
@ -804,6 +797,9 @@ export async function getTableData(
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 최종 검색 조건 로그
|
||||
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
|
||||
|
||||
// 데이터 조회
|
||||
const result = await tableManagementService.getTableData(tableName, {
|
||||
page: parseInt(page),
|
||||
|
|
@ -905,13 +901,23 @@ export async function addTableData(
|
|||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
const result = await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
// 무시된 컬럼이 있으면 경고 정보 포함
|
||||
const response: ApiResponse<{
|
||||
skippedColumns?: string[];
|
||||
savedColumns?: string[];
|
||||
}> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
message: result.skippedColumns.length > 0
|
||||
? `테이블 데이터를 추가했습니다. (무시된 컬럼 ${result.skippedColumns.length}개: ${result.skippedColumns.join(", ")})`
|
||||
: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
data: {
|
||||
skippedColumns: result.skippedColumns.length > 0 ? result.skippedColumns : undefined,
|
||||
savedColumns: result.savedColumns,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
|
|
@ -2179,104 +2185,3 @@ export async function multiTableSave(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 테이블 간 엔티티 관계 조회
|
||||
* column_labels의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회
|
||||
*/
|
||||
export async function getTableEntityRelations(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { leftTable, rightTable } = req.query;
|
||||
|
||||
if (!leftTable || !rightTable) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
|
||||
|
||||
// 두 테이블의 컬럼 라벨 정보 조회
|
||||
const columnLabelsQuery = `
|
||||
SELECT
|
||||
table_name,
|
||||
column_name,
|
||||
column_label,
|
||||
web_type,
|
||||
detail_settings
|
||||
FROM column_labels
|
||||
WHERE table_name IN ($1, $2)
|
||||
AND web_type IN ('entity', 'category')
|
||||
`;
|
||||
|
||||
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
|
||||
|
||||
// 관계 분석
|
||||
const relations: Array<{
|
||||
fromTable: string;
|
||||
fromColumn: string;
|
||||
toTable: string;
|
||||
toColumn: string;
|
||||
relationType: string;
|
||||
}> = [];
|
||||
|
||||
for (const row of result) {
|
||||
try {
|
||||
const detailSettings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings)
|
||||
: row.detail_settings;
|
||||
|
||||
if (detailSettings && detailSettings.referenceTable) {
|
||||
const refTable = detailSettings.referenceTable;
|
||||
const refColumn = detailSettings.referenceColumn || "id";
|
||||
|
||||
// leftTable과 rightTable 간의 관계인지 확인
|
||||
if (
|
||||
(row.table_name === leftTable && refTable === rightTable) ||
|
||||
(row.table_name === rightTable && refTable === leftTable)
|
||||
) {
|
||||
relations.push({
|
||||
fromTable: row.table_name,
|
||||
fromColumn: row.column_name,
|
||||
toTable: refTable,
|
||||
toColumn: refColumn,
|
||||
relationType: row.web_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.warn("detail_settings 파싱 오류:", {
|
||||
table: row.table_name,
|
||||
column: row.column_name,
|
||||
error: parseError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("테이블 엔티티 관계 조회 완료", {
|
||||
leftTable,
|
||||
rightTable,
|
||||
relationsCount: relations.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
leftTable,
|
||||
rightTable,
|
||||
relations,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("테이블 엔티티 관계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "테이블 엔티티 관계 조회에 실패했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,3 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -52,4 +52,3 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,4 +68,3 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -56,4 +56,3 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
mergeCodeAllTables,
|
||||
getTablesWithColumn,
|
||||
previewCodeMerge,
|
||||
mergeCodeByValue,
|
||||
previewMergeCodeByValue,
|
||||
} from "../controllers/codeMergeController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -15,7 +13,7 @@ router.use(authenticateToken);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/merge-all-tables
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||
* Body: { columnName, oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||
|
|
@ -28,24 +26,10 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/preview
|
||||
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
* Body: { columnName, oldValue }
|
||||
*/
|
||||
router.post("/preview", previewCodeMerge);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/merge-by-value
|
||||
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
||||
* Body: { oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-by-value", mergeCodeByValue);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/preview-by-value
|
||||
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
||||
* Body: { oldValue }
|
||||
*/
|
||||
router.post("/preview-by-value", previewMergeCodeByValue);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,262 +1,10 @@
|
|||
import express from "express";
|
||||
import { dataService } from "../services/dataService";
|
||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ================================
|
||||
// 마스터-디테일 엑셀 API
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보 조회
|
||||
* GET /api/data/master-detail/relation/:screenId
|
||||
*/
|
||||
router.get(
|
||||
"/master-detail/relation/:screenId",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
|
||||
if (!screenId || isNaN(parseInt(screenId))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효한 screenId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
|
||||
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: null,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 마스터-디테일 관계 발견:`, {
|
||||
masterTable: relation.masterTable,
|
||||
detailTable: relation.detailTable,
|
||||
joinKey: relation.masterKeyColumn,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: relation,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 관계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 관계 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 다운로드 데이터 조회
|
||||
* POST /api/data/master-detail/download
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/download",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, filters } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!screenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📥 마스터-디테일 엑셀 다운로드: screenId=${screenId}`);
|
||||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. JOIN 데이터 조회
|
||||
const data = await masterDetailExcelService.getJoinedData(
|
||||
relation,
|
||||
companyCode,
|
||||
filters
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}행`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 다운로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 다운로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 업로드
|
||||
* POST /api/data/master-detail/upload
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/upload",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, data } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!screenId || !data || !Array.isArray(data)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 data 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
|
||||
|
||||
// 1. 마스터-디테일 관계 조회
|
||||
const relation = await masterDetailExcelService.getMasterDetailRelation(
|
||||
parseInt(screenId)
|
||||
);
|
||||
|
||||
if (!relation) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 구조가 아닙니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 데이터 업로드
|
||||
const result = await masterDetailExcelService.uploadJoinedData(
|
||||
relation,
|
||||
data,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 업로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 마스터-디테일 간단 모드 엑셀 업로드
|
||||
* - 마스터 정보는 UI에서 선택
|
||||
* - 디테일 정보만 엑셀에서 업로드
|
||||
* - 채번 규칙을 통해 마스터 키 자동 생성
|
||||
*
|
||||
* POST /api/data/master-detail/upload-simple
|
||||
*/
|
||||
router.post(
|
||||
"/master-detail/upload-simple",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "system";
|
||||
|
||||
if (!screenId || !detailData || !Array.isArray(detailData)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "screenId와 detailData 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
|
||||
console.log(` 마스터 필드 값:`, masterFieldValues);
|
||||
console.log(` 채번 규칙 ID:`, numberingRuleId);
|
||||
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}개` : afterUploadFlowId || "없음");
|
||||
|
||||
// 업로드 실행
|
||||
const result = await masterDetailExcelService.uploadSimple(
|
||||
parseInt(screenId),
|
||||
detailData,
|
||||
masterFieldValues || {},
|
||||
numberingRuleId,
|
||||
companyCode,
|
||||
userId,
|
||||
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
|
||||
afterUploadFlows // 업로드 후 제어 실행 (다중)
|
||||
);
|
||||
|
||||
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
detailInserted: result.detailInserted,
|
||||
generatedKey: result.generatedKey,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 1건(${result.generatedKey}), 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("마스터-디테일 간단 모드 업로드 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "마스터-디테일 업로드 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ================================
|
||||
// 기존 데이터 API
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||
|
|
@ -950,7 +698,6 @@ router.post(
|
|||
try {
|
||||
const { tableName } = req.params;
|
||||
const filterConditions = req.body;
|
||||
const userCompany = req.user?.companyCode;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -959,12 +706,11 @@ router.post(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
const result = await dataService.deleteGroupRecords(
|
||||
tableName,
|
||||
filterConditions,
|
||||
userCompany // 회사 코드 전달
|
||||
filterConditions
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
findMappingByColumns,
|
||||
saveMappingTemplate,
|
||||
getMappingTemplates,
|
||||
deleteMappingTemplate,
|
||||
} from "../controllers/excelMappingController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
router.post("/find", authenticateToken, findMappingByColumns);
|
||||
|
||||
// 매핑 템플릿 저장 (UPSERT)
|
||||
router.post("/save", authenticateToken, saveMappingTemplate);
|
||||
|
||||
// 테이블의 매핑 템플릿 목록 조회
|
||||
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
|
||||
|
||||
// 매핑 템플릿 삭제
|
||||
router.delete("/:id", authenticateToken, deleteMappingTemplate);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -21,20 +21,6 @@ import {
|
|||
getUserText,
|
||||
getLangText,
|
||||
getBatchTranslations,
|
||||
|
||||
// 카테고리 관리 API
|
||||
getCategories,
|
||||
getCategoryById,
|
||||
getCategoryPath,
|
||||
|
||||
// 자동 생성 및 오버라이드 API
|
||||
generateKey,
|
||||
previewKey,
|
||||
createOverrideKey,
|
||||
getOverrideKeys,
|
||||
|
||||
// 화면 라벨 다국어 API
|
||||
generateScreenLabelKeys,
|
||||
} from "../controllers/multilangController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -65,18 +51,4 @@ router.post("/keys/:keyId/texts", saveLangTexts); // 다국어 텍스트 저장/
|
|||
router.get("/user-text/:companyCode/:menuCode/:langKey", getUserText); // 사용자별 다국어 텍스트 조회
|
||||
router.get("/text/:companyCode/:langKey/:langCode", getLangText); // 특정 키의 다국어 텍스트 조회
|
||||
|
||||
// 카테고리 관리 API
|
||||
router.get("/categories", getCategories); // 카테고리 트리 조회
|
||||
router.get("/categories/:categoryId", getCategoryById); // 카테고리 상세 조회
|
||||
router.get("/categories/:categoryId/path", getCategoryPath); // 카테고리 경로 조회
|
||||
|
||||
// 자동 생성 및 오버라이드 API
|
||||
router.post("/keys/generate", generateKey); // 키 자동 생성
|
||||
router.post("/keys/preview", previewKey); // 키 미리보기
|
||||
router.post("/keys/override", createOverrideKey); // 오버라이드 키 생성
|
||||
router.get("/keys/overrides/:companyCode", getOverrideKeys); // 오버라이드 키 목록 조회
|
||||
|
||||
// 화면 라벨 다국어 자동 생성 API
|
||||
router.post("/screen-labels", generateScreenLabelKeys); // 화면 라벨 다국어 키 자동 생성
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
// 화면 그룹
|
||||
getScreenGroups,
|
||||
getScreenGroup,
|
||||
createScreenGroup,
|
||||
updateScreenGroup,
|
||||
deleteScreenGroup,
|
||||
// 화면-그룹 연결
|
||||
addScreenToGroup,
|
||||
removeScreenFromGroup,
|
||||
updateScreenInGroup,
|
||||
// 필드 조인
|
||||
getFieldJoins,
|
||||
createFieldJoin,
|
||||
updateFieldJoin,
|
||||
deleteFieldJoin,
|
||||
// 데이터 흐름
|
||||
getDataFlows,
|
||||
createDataFlow,
|
||||
updateDataFlow,
|
||||
deleteDataFlow,
|
||||
// 화면-테이블 관계
|
||||
getTableRelations,
|
||||
createTableRelation,
|
||||
updateTableRelation,
|
||||
deleteTableRelation,
|
||||
// 화면 레이아웃 요약
|
||||
getScreenLayoutSummary,
|
||||
getMultipleScreenLayoutSummary,
|
||||
// 화면 서브 테이블 관계
|
||||
getScreenSubTables,
|
||||
// 메뉴-화면그룹 동기화
|
||||
syncScreenGroupsToMenuController,
|
||||
syncMenuToScreenGroupsController,
|
||||
getSyncStatusController,
|
||||
syncAllCompaniesController,
|
||||
} from "../controllers/screenGroupController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ============================================================
|
||||
// 화면 그룹 (screen_groups)
|
||||
// ============================================================
|
||||
router.get("/groups", getScreenGroups);
|
||||
router.get("/groups/:id", getScreenGroup);
|
||||
router.post("/groups", createScreenGroup);
|
||||
router.put("/groups/:id", updateScreenGroup);
|
||||
router.delete("/groups/:id", deleteScreenGroup);
|
||||
|
||||
// ============================================================
|
||||
// 화면-그룹 연결 (screen_group_screens)
|
||||
// ============================================================
|
||||
router.post("/group-screens", addScreenToGroup);
|
||||
router.put("/group-screens/:id", updateScreenInGroup);
|
||||
router.delete("/group-screens/:id", removeScreenFromGroup);
|
||||
|
||||
// ============================================================
|
||||
// 필드 조인 설정 (screen_field_joins)
|
||||
// ============================================================
|
||||
router.get("/field-joins", getFieldJoins);
|
||||
router.post("/field-joins", createFieldJoin);
|
||||
router.put("/field-joins/:id", updateFieldJoin);
|
||||
router.delete("/field-joins/:id", deleteFieldJoin);
|
||||
|
||||
// ============================================================
|
||||
// 데이터 흐름 (screen_data_flows)
|
||||
// ============================================================
|
||||
router.get("/data-flows", getDataFlows);
|
||||
router.post("/data-flows", createDataFlow);
|
||||
router.put("/data-flows/:id", updateDataFlow);
|
||||
router.delete("/data-flows/:id", deleteDataFlow);
|
||||
|
||||
// ============================================================
|
||||
// 화면-테이블 관계 (screen_table_relations)
|
||||
// ============================================================
|
||||
router.get("/table-relations", getTableRelations);
|
||||
router.post("/table-relations", createTableRelation);
|
||||
router.put("/table-relations/:id", updateTableRelation);
|
||||
router.delete("/table-relations/:id", deleteTableRelation);
|
||||
|
||||
// ============================================================
|
||||
// 화면 레이아웃 요약 (미리보기용)
|
||||
// ============================================================
|
||||
router.get("/layout-summary/:screenId", getScreenLayoutSummary);
|
||||
router.post("/layout-summary/batch", getMultipleScreenLayoutSummary);
|
||||
|
||||
// ============================================================
|
||||
// 화면 서브 테이블 관계 (조인/참조 테이블)
|
||||
// ============================================================
|
||||
router.post("/sub-tables/batch", getScreenSubTables);
|
||||
|
||||
// ============================================================
|
||||
// 메뉴-화면그룹 동기화
|
||||
// ============================================================
|
||||
// 동기화 상태 조회
|
||||
router.get("/sync/status", getSyncStatusController);
|
||||
// 화면관리 → 메뉴 동기화
|
||||
router.post("/sync/screen-to-menu", syncScreenGroupsToMenuController);
|
||||
// 메뉴 → 화면관리 동기화
|
||||
router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
||||
// 전체 회사 동기화 (최고 관리자만)
|
||||
router.post("/sync/all", syncAllCompaniesController);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
toggleLogTable,
|
||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -39,15 +38,6 @@ router.use(authenticateToken);
|
|||
*/
|
||||
router.get("/tables", getTableList);
|
||||
|
||||
/**
|
||||
* 두 테이블 간 엔티티 관계 조회
|
||||
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||
*
|
||||
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||
*/
|
||||
router.get("/tables/entity-relations", getTableEntityRelations);
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회
|
||||
* GET /api/table-management/tables/:tableName/columns
|
||||
|
|
|
|||
|
|
@ -254,10 +254,7 @@ class DataService {
|
|||
key !== "limit" &&
|
||||
key !== "offset" &&
|
||||
key !== "orderBy" &&
|
||||
key !== "userLang" &&
|
||||
key !== "page" &&
|
||||
key !== "pageSize" &&
|
||||
key !== "size"
|
||||
key !== "userLang"
|
||||
) {
|
||||
// 컬럼명 검증 (SQL 인젝션 방지)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
|
|
@ -1192,13 +1189,6 @@ class DataService {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
||||
pkColumns: pkResult.map((r) => r.attname),
|
||||
pkCount: pkResult.length,
|
||||
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
||||
inputIdType: typeof id,
|
||||
});
|
||||
|
||||
let whereClauses: string[] = [];
|
||||
let params: any[] = [];
|
||||
|
||||
|
|
@ -1226,31 +1216,17 @@ class DataService {
|
|||
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||
}
|
||||
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||
|
||||
const result = await query<any>(queryText, params);
|
||||
|
||||
// 삭제된 행이 없으면 실패 처리
|
||||
if (result.length === 0) {
|
||||
console.warn(
|
||||
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
||||
{ whereClauses, params }
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0], // 삭제된 레코드 정보 반환
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||
|
|
@ -1264,14 +1240,10 @@ class DataService {
|
|||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
* @param tableName 테이블명
|
||||
* @param filterConditions 삭제 조건
|
||||
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||||
*/
|
||||
async deleteGroupRecords(
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>,
|
||||
userCompany?: string
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||
try {
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
|
|
@ -1283,7 +1255,6 @@ class DataService {
|
|||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 사용자 필터 조건 추가
|
||||
for (const [key, value] of Object.entries(filterConditions)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
|
|
@ -1298,24 +1269,10 @@ class DataService {
|
|||
};
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
|
||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||
if (hasCompanyCode && userCompany && userCompany !== "*") {
|
||||
whereConditions.push(`"company_code" = $${paramIndex}`);
|
||||
whereValues.push(userCompany);
|
||||
paramIndex++;
|
||||
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, {
|
||||
tableName,
|
||||
conditions: filterConditions,
|
||||
userCompany,
|
||||
whereClause,
|
||||
});
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
||||
|
||||
const result = await pool.query(deleteQuery, whereValues);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
import tableCategoryValueService from "./tableCategoryValueService";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
|
|
@ -428,24 +427,6 @@ export class DynamicFormService {
|
|||
dataToInsert,
|
||||
});
|
||||
|
||||
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
|
||||
const companyCodeForCategory = company_code || "*";
|
||||
const { convertedData: categoryConvertedData, conversions } =
|
||||
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
|
||||
tableName,
|
||||
companyCodeForCategory,
|
||||
dataToInsert
|
||||
);
|
||||
|
||||
if (conversions.length > 0) {
|
||||
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}개`, conversions);
|
||||
// 변환된 데이터로 교체
|
||||
Object.assign(dataToInsert, categoryConvertedData);
|
||||
} else {
|
||||
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정보 조회하여 타입 변환 적용
|
||||
console.log("🔍 테이블 컬럼 정보 조회 중...");
|
||||
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||
|
|
@ -1192,18 +1173,12 @@ export class DynamicFormService {
|
|||
|
||||
/**
|
||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||
* @param id 삭제할 레코드 ID
|
||||
* @param tableName 테이블명
|
||||
* @param companyCode 회사 코드
|
||||
* @param userId 사용자 ID
|
||||
* @param screenId 화면 ID (제어관리 실행용, 선택사항)
|
||||
*/
|
||||
async deleteFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
companyCode?: string,
|
||||
userId?: string,
|
||||
screenId?: number
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
|
|
@ -1316,19 +1291,14 @@ export class DynamicFormService {
|
|||
const recordCompanyCode =
|
||||
deletedRecord?.company_code || companyCode || "*";
|
||||
|
||||
// screenId가 전달되지 않으면 제어관리를 실행하지 않음
|
||||
if (screenId && screenId > 0) {
|
||||
await this.executeDataflowControlIfConfigured(
|
||||
screenId,
|
||||
tableName,
|
||||
deletedRecord,
|
||||
"delete",
|
||||
userId || "system",
|
||||
recordCompanyCode
|
||||
);
|
||||
} else {
|
||||
console.log("ℹ️ screenId가 전달되지 않아 제어관리를 건너뜁니다. (screenId:", screenId, ")");
|
||||
}
|
||||
await this.executeDataflowControlIfConfigured(
|
||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||
tableName,
|
||||
deletedRecord,
|
||||
"delete",
|
||||
userId || "system",
|
||||
recordCompanyCode
|
||||
);
|
||||
}
|
||||
} catch (controlError) {
|
||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||
|
|
@ -1673,16 +1643,10 @@ export class DynamicFormService {
|
|||
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
|
||||
});
|
||||
|
||||
// 버튼 컴포넌트이고 제어관리가 활성화된 경우
|
||||
// triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete
|
||||
const buttonActionType = properties?.componentConfig?.action?.type;
|
||||
const isMatchingAction =
|
||||
(triggerType === "delete" && buttonActionType === "delete") ||
|
||||
((triggerType === "insert" || triggerType === "update") && buttonActionType === "save");
|
||||
|
||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||
if (
|
||||
properties?.componentType === "button-primary" &&
|
||||
isMatchingAction &&
|
||||
properties?.componentConfig?.action?.type === "save" &&
|
||||
properties?.webTypeConfig?.enableDataflowControl === true
|
||||
) {
|
||||
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface ExcelMappingTemplate {
|
||||
id?: number;
|
||||
tableName: string;
|
||||
excelColumns: string[];
|
||||
excelColumnsHash: string;
|
||||
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
|
||||
companyCode: string;
|
||||
createdDate?: Date;
|
||||
updatedDate?: Date;
|
||||
}
|
||||
|
||||
class ExcelMappingService {
|
||||
/**
|
||||
* 엑셀 컬럼 목록으로 해시 생성
|
||||
* 정렬 후 MD5 해시 생성하여 동일한 컬럼 구조 식별
|
||||
*/
|
||||
generateColumnsHash(columns: string[]): string {
|
||||
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
|
||||
const sortedColumns = [...columns].sort();
|
||||
const columnsString = sortedColumns.join("|");
|
||||
return crypto.createHash("md5").update(columnsString).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 구조로 매핑 템플릿 조회
|
||||
* 동일한 컬럼 구조가 있으면 기존 매핑 반환
|
||||
*/
|
||||
async findMappingByColumns(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate | null> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 조회", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND excel_columns_hash = $2
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
ORDER BY
|
||||
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
|
||||
updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [tableName, hash, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
logger.info("기존 매핑 템플릿 발견", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
});
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 저장 (UPSERT)
|
||||
* 동일한 테이블+컬럼구조+회사코드가 있으면 업데이트, 없으면 삽입
|
||||
*/
|
||||
async saveMappingTemplate(
|
||||
tableName: string,
|
||||
excelColumns: string[],
|
||||
columnMappings: Record<string, string | null>,
|
||||
companyCode: string,
|
||||
userId?: string
|
||||
): Promise<ExcelMappingTemplate> {
|
||||
try {
|
||||
const hash = this.generateColumnsHash(excelColumns);
|
||||
|
||||
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
columnMappings,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
INSERT INTO excel_mapping_template (
|
||||
table_name,
|
||||
excel_columns,
|
||||
excel_columns_hash,
|
||||
column_mappings,
|
||||
company_code,
|
||||
created_date,
|
||||
updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (table_name, excel_columns_hash, company_code)
|
||||
DO UPDATE SET
|
||||
column_mappings = EXCLUDED.column_mappings,
|
||||
updated_date = NOW()
|
||||
RETURNING
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
tableName,
|
||||
excelColumns,
|
||||
hash,
|
||||
JSON.stringify(columnMappings),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
logger.info("매핑 템플릿 저장 완료", {
|
||||
id: result.rows[0].id,
|
||||
tableName,
|
||||
hash,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 모든 매핑 템플릿 조회
|
||||
*/
|
||||
async getMappingTemplates(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<ExcelMappingTemplate[]> {
|
||||
try {
|
||||
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName];
|
||||
} else {
|
||||
query = `
|
||||
SELECT
|
||||
id,
|
||||
table_name as "tableName",
|
||||
excel_columns as "excelColumns",
|
||||
excel_columns_hash as "excelColumnsHash",
|
||||
column_mappings as "columnMappings",
|
||||
company_code as "companyCode",
|
||||
created_date as "createdDate",
|
||||
updated_date as "updatedDate"
|
||||
FROM excel_mapping_template
|
||||
WHERE table_name = $1
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY updated_date DESC
|
||||
`;
|
||||
params = [tableName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 템플릿 삭제
|
||||
*/
|
||||
async deleteMappingTemplate(
|
||||
id: number,
|
||||
companyCode: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info("매핑 템플릿 삭제", { id, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
|
||||
params = [id];
|
||||
} else {
|
||||
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
|
||||
params = [id, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
logger.info("매핑 템플릿 삭제 완료", { id });
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExcelMappingService();
|
||||
|
||||
|
|
@ -1,908 +0,0 @@
|
|||
/**
|
||||
* 마스터-디테일 엑셀 처리 서비스
|
||||
*
|
||||
* 분할 패널 화면의 마스터-디테일 구조를 자동 감지하고
|
||||
* 엑셀 다운로드/업로드 시 JOIN 및 그룹화 처리를 수행합니다.
|
||||
*/
|
||||
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ================================
|
||||
// 인터페이스 정의
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보
|
||||
*/
|
||||
export interface MasterDetailRelation {
|
||||
masterTable: string;
|
||||
detailTable: string;
|
||||
masterKeyColumn: string; // 마스터 테이블의 키 컬럼 (예: order_no)
|
||||
detailFkColumn: string; // 디테일 테이블의 FK 컬럼 (예: order_no)
|
||||
masterColumns: ColumnInfo[];
|
||||
detailColumns: ColumnInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 정보
|
||||
*/
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
inputType: string;
|
||||
isFromMaster: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정
|
||||
*/
|
||||
export interface SplitPanelConfig {
|
||||
leftPanel: {
|
||||
tableName: string;
|
||||
columns: Array<{ name: string; label: string; width?: number }>;
|
||||
};
|
||||
rightPanel: {
|
||||
tableName: string;
|
||||
columns: Array<{ name: string; label: string; width?: number }>;
|
||||
relation?: {
|
||||
type: string;
|
||||
foreignKey?: string;
|
||||
leftColumn?: string;
|
||||
// 복합키 지원 (새로운 방식)
|
||||
keys?: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 결과
|
||||
*/
|
||||
export interface ExcelDownloadData {
|
||||
headers: string[]; // 컬럼 라벨들
|
||||
columns: string[]; // 컬럼명들
|
||||
data: Record<string, any>[];
|
||||
masterColumns: string[]; // 마스터 컬럼 목록
|
||||
detailColumns: string[]; // 디테일 컬럼 목록
|
||||
joinKey: string; // 조인 키
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 결과
|
||||
*/
|
||||
export interface ExcelUploadResult {
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
masterUpdated: number;
|
||||
detailInserted: number;
|
||||
detailDeleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// ================================
|
||||
// 서비스 클래스
|
||||
// ================================
|
||||
|
||||
class MasterDetailExcelService {
|
||||
|
||||
/**
|
||||
* 화면 ID로 분할 패널 설정 조회
|
||||
*/
|
||||
async getSplitPanelConfig(screenId: number): Promise<SplitPanelConfig | null> {
|
||||
try {
|
||||
logger.info(`분할 패널 설정 조회: screenId=${screenId}`);
|
||||
|
||||
// screen_layouts에서 split-panel-layout 컴포넌트 찾기
|
||||
const result = await queryOne<any>(
|
||||
`SELECT properties->>'componentConfig' as config
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties->>'componentType' = 'split-panel-layout'
|
||||
LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
if (!result || !result.config) {
|
||||
logger.info(`분할 패널 없음: screenId=${screenId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = typeof result.config === "string"
|
||||
? JSON.parse(result.config)
|
||||
: result.config;
|
||||
|
||||
logger.info(`분할 패널 설정 발견:`, {
|
||||
leftTable: config.leftPanel?.tableName,
|
||||
rightTable: config.rightPanel?.tableName,
|
||||
relation: config.rightPanel?.relation,
|
||||
});
|
||||
|
||||
return {
|
||||
leftPanel: config.leftPanel,
|
||||
rightPanel: config.rightPanel,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`분할 패널 설정 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* column_labels에서 Entity 관계 정보 조회
|
||||
* 디테일 테이블에서 마스터 테이블을 참조하는 컬럼 찾기
|
||||
*/
|
||||
async getEntityRelation(
|
||||
detailTable: string,
|
||||
masterTable: string
|
||||
): Promise<{ detailFkColumn: string; masterKeyColumn: string } | null> {
|
||||
try {
|
||||
logger.info(`Entity 관계 조회: ${detailTable} -> ${masterTable}`);
|
||||
|
||||
const result = await queryOne<any>(
|
||||
`SELECT column_name, reference_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'entity'
|
||||
AND reference_table = $2
|
||||
LIMIT 1`,
|
||||
[detailTable, masterTable]
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
logger.warn(`Entity 관계 없음: ${detailTable} -> ${masterTable}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`Entity 관계 발견: ${detailTable}.${result.column_name} -> ${masterTable}.${result.reference_column}`);
|
||||
|
||||
return {
|
||||
detailFkColumn: result.column_name,
|
||||
masterKeyColumn: result.reference_column,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`Entity 관계 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 컬럼 라벨 정보 조회
|
||||
*/
|
||||
async getColumnLabels(tableName: string): Promise<Map<string, string>> {
|
||||
try {
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const labelMap = new Map<string, string>();
|
||||
for (const row of result) {
|
||||
labelMap.set(row.column_name, row.column_label || row.column_name);
|
||||
}
|
||||
|
||||
return labelMap;
|
||||
} catch (error: any) {
|
||||
logger.error(`컬럼 라벨 조회 실패: ${error.message}`);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 관계 정보 조합
|
||||
*/
|
||||
async getMasterDetailRelation(
|
||||
screenId: number
|
||||
): Promise<MasterDetailRelation | null> {
|
||||
try {
|
||||
// 1. 분할 패널 설정 조회
|
||||
const splitPanel = await this.getSplitPanelConfig(screenId);
|
||||
if (!splitPanel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const masterTable = splitPanel.leftPanel.tableName;
|
||||
const detailTable = splitPanel.rightPanel.tableName;
|
||||
|
||||
if (!masterTable || !detailTable) {
|
||||
logger.warn("마스터 또는 디테일 테이블명 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
|
||||
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
|
||||
let masterKeyColumn: string | undefined;
|
||||
let detailFkColumn: string | undefined;
|
||||
|
||||
const relationKeys = splitPanel.rightPanel.relation?.keys;
|
||||
if (relationKeys && relationKeys.length > 0) {
|
||||
// keys 배열에서 첫 번째 키 사용
|
||||
masterKeyColumn = relationKeys[0].leftColumn;
|
||||
detailFkColumn = relationKeys[0].rightColumn;
|
||||
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
|
||||
} else {
|
||||
// 하위 호환성: 기존 leftColumn/foreignKey 사용
|
||||
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
|
||||
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
|
||||
}
|
||||
|
||||
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
|
||||
if (!masterKeyColumn || !detailFkColumn) {
|
||||
const entityRelation = await this.getEntityRelation(detailTable, masterTable);
|
||||
if (entityRelation) {
|
||||
masterKeyColumn = entityRelation.masterKeyColumn;
|
||||
detailFkColumn = entityRelation.detailFkColumn;
|
||||
}
|
||||
}
|
||||
|
||||
if (!masterKeyColumn || !detailFkColumn) {
|
||||
logger.warn("조인 키 정보를 찾을 수 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. 컬럼 라벨 정보 조회
|
||||
const masterLabels = await this.getColumnLabels(masterTable);
|
||||
const detailLabels = await this.getColumnLabels(detailTable);
|
||||
|
||||
// 5. 마스터 컬럼 정보 구성
|
||||
const masterColumns: ColumnInfo[] = splitPanel.leftPanel.columns.map(col => ({
|
||||
name: col.name,
|
||||
label: masterLabels.get(col.name) || col.label || col.name,
|
||||
inputType: "text",
|
||||
isFromMaster: true,
|
||||
}));
|
||||
|
||||
// 6. 디테일 컬럼 정보 구성 (FK 컬럼 제외)
|
||||
const detailColumns: ColumnInfo[] = splitPanel.rightPanel.columns
|
||||
.filter(col => col.name !== detailFkColumn) // FK 컬럼 제외
|
||||
.map(col => ({
|
||||
name: col.name,
|
||||
label: detailLabels.get(col.name) || col.label || col.name,
|
||||
inputType: "text",
|
||||
isFromMaster: false,
|
||||
}));
|
||||
|
||||
logger.info(`마스터-디테일 관계 구성 완료:`, {
|
||||
masterTable,
|
||||
detailTable,
|
||||
masterKeyColumn,
|
||||
detailFkColumn,
|
||||
masterColumnCount: masterColumns.length,
|
||||
detailColumnCount: detailColumns.length,
|
||||
});
|
||||
|
||||
return {
|
||||
masterTable,
|
||||
detailTable,
|
||||
masterKeyColumn,
|
||||
detailFkColumn,
|
||||
masterColumns,
|
||||
detailColumns,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`마스터-디테일 관계 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 JOIN 데이터 조회 (엑셀 다운로드용)
|
||||
*/
|
||||
async getJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
companyCode: string,
|
||||
filters?: Record<string, any>
|
||||
): Promise<ExcelDownloadData> {
|
||||
try {
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 조인 컬럼과 일반 컬럼 분리
|
||||
// 조인 컬럼 형식: "테이블명.컬럼명" (예: customer_mng.customer_name)
|
||||
const entityJoins: Array<{
|
||||
refTable: string;
|
||||
refColumn: string;
|
||||
sourceColumn: string;
|
||||
alias: string;
|
||||
displayColumn: string;
|
||||
}> = [];
|
||||
|
||||
// SELECT 절 구성
|
||||
const selectParts: string[] = [];
|
||||
let aliasIndex = 0;
|
||||
|
||||
// 마스터 컬럼 처리
|
||||
for (const col of masterColumns) {
|
||||
if (col.name.includes(".")) {
|
||||
// 조인 컬럼: 테이블명.컬럼명
|
||||
const [refTable, displayColumn] = col.name.split(".");
|
||||
const alias = `ej${aliasIndex++}`;
|
||||
|
||||
// column_labels에서 FK 컬럼 찾기
|
||||
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
|
||||
if (fkColumn) {
|
||||
entityJoins.push({
|
||||
refTable,
|
||||
refColumn: fkColumn.referenceColumn,
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
// FK를 못 찾으면 NULL로 처리
|
||||
selectParts.push(`NULL AS "${col.name}"`);
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼
|
||||
selectParts.push(`m."${col.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// 디테일 컬럼 처리
|
||||
for (const col of detailColumns) {
|
||||
if (col.name.includes(".")) {
|
||||
// 조인 컬럼: 테이블명.컬럼명
|
||||
const [refTable, displayColumn] = col.name.split(".");
|
||||
const alias = `ej${aliasIndex++}`;
|
||||
|
||||
// column_labels에서 FK 컬럼 찾기
|
||||
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
|
||||
if (fkColumn) {
|
||||
entityJoins.push({
|
||||
refTable,
|
||||
refColumn: fkColumn.referenceColumn,
|
||||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
selectParts.push(`NULL AS "${col.name}"`);
|
||||
}
|
||||
} else {
|
||||
// 일반 컬럼
|
||||
selectParts.push(`d."${col.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClause = selectParts.join(", ");
|
||||
|
||||
// 엔티티 조인 절 구성
|
||||
const entityJoinClauses = entityJoins.map(ej =>
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
).join("\n ");
|
||||
|
||||
// WHERE 절 구성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터 (최고 관리자 제외)
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereConditions.push(`m.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 추가 필터 적용
|
||||
if (filters) {
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
// 조인 컬럼인지 확인
|
||||
if (key.includes(".")) continue;
|
||||
// 마스터 테이블 컬럼인지 확인
|
||||
const isMasterCol = masterColumns.some(c => c.name === key);
|
||||
const tableAlias = isMasterCol ? "m" : "d";
|
||||
whereConditions.push(`${tableAlias}."${key}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
FROM "${masterTable}" m
|
||||
LEFT JOIN "${detailTable}" d
|
||||
ON m."${masterKeyColumn}" = d."${detailFkColumn}"
|
||||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
|
||||
const data = await query<any>(sql, params);
|
||||
|
||||
// 헤더 및 컬럼 정보 구성
|
||||
const headers = [...masterColumns.map(c => c.label), ...detailColumns.map(c => c.label)];
|
||||
const columns = [...masterColumns.map(c => c.name), ...detailColumns.map(c => c.name)];
|
||||
|
||||
logger.info(`마스터-디테일 데이터 조회 완료: ${data.length}행`);
|
||||
|
||||
return {
|
||||
headers,
|
||||
columns,
|
||||
data,
|
||||
masterColumns: masterColumns.map(c => c.name),
|
||||
detailColumns: detailColumns.map(c => c.name),
|
||||
joinKey: masterKeyColumn,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error(`마스터-디테일 데이터 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블에서 참조 테이블로의 FK 컬럼 찾기
|
||||
*/
|
||||
private async findForeignKeyColumn(
|
||||
sourceTable: string,
|
||||
referenceTable: string
|
||||
): Promise<{ sourceColumn: string; referenceColumn: string } | null> {
|
||||
try {
|
||||
const result = await query<{ column_name: string; reference_column: string }>(
|
||||
`SELECT column_name, reference_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND reference_table = $2
|
||||
AND input_type = 'entity'
|
||||
LIMIT 1`,
|
||||
[sourceTable, referenceTable]
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
return {
|
||||
sourceColumn: result[0].column_name,
|
||||
referenceColumn: result[0].reference_column,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`FK 컬럼 조회 실패: ${sourceTable} -> ${referenceTable}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
data: Record<string, any>[],
|
||||
companyCode: string,
|
||||
userId?: string
|
||||
): Promise<ExcelUploadResult> {
|
||||
const result: ExcelUploadResult = {
|
||||
success: false,
|
||||
masterInserted: 0,
|
||||
masterUpdated: 0,
|
||||
detailInserted: 0,
|
||||
detailDeleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
|
||||
// 2. 각 그룹 처리
|
||||
for (const [masterKey, rows] of groupedData.entries()) {
|
||||
try {
|
||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||
const masterData: Record<string, any> = {};
|
||||
for (const col of masterColumns) {
|
||||
if (rows[0][col.name] !== undefined) {
|
||||
masterData[col.name] = rows[0][col.name];
|
||||
}
|
||||
}
|
||||
|
||||
// 회사 코드, 작성자 추가
|
||||
masterData.company_code = companyCode;
|
||||
if (userId) {
|
||||
masterData.writer = userId;
|
||||
}
|
||||
|
||||
// 2b. 마스터 UPSERT
|
||||
const existingMaster = await client.query(
|
||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// INSERT
|
||||
const insertCols = Object.keys(masterData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
// 2c. 기존 디테일 삭제
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
|
||||
// 2d. 새 디테일 INSERT
|
||||
for (const row of rows) {
|
||||
const detailData: Record<string, any> = {};
|
||||
|
||||
// FK 컬럼 추가
|
||||
detailData[detailFkColumn] = masterKey;
|
||||
detailData.company_code = companyCode;
|
||||
if (userId) {
|
||||
detailData.writer = userId;
|
||||
}
|
||||
|
||||
// 디테일 컬럼 데이터 추출
|
||||
for (const col of detailColumns) {
|
||||
if (row[col.name] !== undefined) {
|
||||
detailData[col.name] = row[col.name];
|
||||
}
|
||||
}
|
||||
|
||||
const insertCols = Object.keys(detailData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => detailData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.detailInserted++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
result.success = result.errors.length === 0 || result.masterInserted + result.masterUpdated > 0;
|
||||
|
||||
logger.info(`마스터-디테일 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailDeleted: result.detailDeleted,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||
logger.error(`마스터-디테일 업로드 트랜잭션 실패:`, error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 간단 모드 업로드
|
||||
*
|
||||
* 마스터 정보는 UI에서 선택하고, 엑셀은 디테일 데이터만 포함
|
||||
* 채번 규칙을 통해 마스터 키 자동 생성
|
||||
*
|
||||
* @param screenId 화면 ID
|
||||
* @param detailData 디테일 데이터 배열
|
||||
* @param masterFieldValues UI에서 선택한 마스터 필드 값
|
||||
* @param numberingRuleId 채번 규칙 ID (optional)
|
||||
* @param companyCode 회사 코드
|
||||
* @param userId 사용자 ID
|
||||
* @param afterUploadFlowId 업로드 후 실행할 노드 플로우 ID (optional, 하위 호환성)
|
||||
* @param afterUploadFlows 업로드 후 실행할 노드 플로우 배열 (optional)
|
||||
*/
|
||||
async uploadSimple(
|
||||
screenId: number,
|
||||
detailData: Record<string, any>[],
|
||||
masterFieldValues: Record<string, any>,
|
||||
numberingRuleId: string | undefined,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
afterUploadFlowId?: string,
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
detailInserted: number;
|
||||
generatedKey: string;
|
||||
errors: string[];
|
||||
controlResult?: any;
|
||||
}> {
|
||||
const result: {
|
||||
success: boolean;
|
||||
masterInserted: number;
|
||||
detailInserted: number;
|
||||
generatedKey: string;
|
||||
errors: string[];
|
||||
controlResult?: any;
|
||||
} = {
|
||||
success: false,
|
||||
masterInserted: 0,
|
||||
detailInserted: 0,
|
||||
generatedKey: "",
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 마스터-디테일 관계 정보 조회
|
||||
const relation = await this.getMasterDetailRelation(screenId);
|
||||
if (!relation) {
|
||||
throw new Error("마스터-디테일 관계 정보를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn } = relation;
|
||||
|
||||
// 2. 채번 처리
|
||||
let generatedKey: string;
|
||||
|
||||
if (numberingRuleId) {
|
||||
// 채번 규칙으로 키 생성
|
||||
generatedKey = await this.generateNumberWithRule(client, numberingRuleId, companyCode);
|
||||
} else {
|
||||
// 채번 규칙 없으면 마스터 필드에서 키 값 사용
|
||||
generatedKey = masterFieldValues[masterKeyColumn];
|
||||
if (!generatedKey) {
|
||||
throw new Error(`마스터 키(${masterKeyColumn}) 값이 필요합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
result.generatedKey = generatedKey;
|
||||
logger.info(`채번 결과: ${generatedKey}`);
|
||||
|
||||
// 3. 마스터 레코드 생성
|
||||
const masterData: Record<string, any> = {
|
||||
...masterFieldValues,
|
||||
[masterKeyColumn]: generatedKey,
|
||||
company_code: companyCode,
|
||||
writer: userId,
|
||||
};
|
||||
|
||||
// 마스터 컬럼명 목록 구성
|
||||
const masterCols = Object.keys(masterData).filter(k => masterData[k] !== undefined);
|
||||
const masterPlaceholders = masterCols.map((_, i) => `$${i + 1}`);
|
||||
const masterValues = masterCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${masterCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${masterPlaceholders.join(", ")}, NOW())`,
|
||||
masterValues
|
||||
);
|
||||
result.masterInserted = 1;
|
||||
logger.info(`마스터 레코드 생성: ${masterTable}, key=${generatedKey}`);
|
||||
|
||||
// 4. 디테일 레코드들 생성 (삽입된 데이터 수집)
|
||||
const insertedDetailRows: Record<string, any>[] = [];
|
||||
|
||||
for (const row of detailData) {
|
||||
try {
|
||||
const detailRowData: Record<string, any> = {
|
||||
...row,
|
||||
[detailFkColumn]: generatedKey,
|
||||
company_code: companyCode,
|
||||
writer: userId,
|
||||
};
|
||||
|
||||
// 빈 값 필터링 및 id 제외
|
||||
const detailCols = Object.keys(detailRowData).filter(k =>
|
||||
k !== "id" &&
|
||||
detailRowData[k] !== undefined &&
|
||||
detailRowData[k] !== null &&
|
||||
detailRowData[k] !== ""
|
||||
);
|
||||
const detailPlaceholders = detailCols.map((_, i) => `$${i + 1}`);
|
||||
const detailValues = detailCols.map(k => detailRowData[k]);
|
||||
|
||||
// RETURNING *로 삽입된 데이터 반환받기
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO "${detailTable}" (${detailCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${detailPlaceholders.join(", ")}, NOW())
|
||||
RETURNING *`,
|
||||
detailValues
|
||||
);
|
||||
|
||||
if (insertResult.rows && insertResult.rows[0]) {
|
||||
insertedDetailRows.push(insertResult.rows[0]);
|
||||
}
|
||||
|
||||
result.detailInserted++;
|
||||
} catch (error: any) {
|
||||
result.errors.push(`디테일 행 처리 실패: ${error.message}`);
|
||||
logger.error(`디테일 행 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`디테일 레코드 ${insertedDetailRows.length}건 삽입 완료`);
|
||||
|
||||
await client.query("COMMIT");
|
||||
result.success = result.errors.length === 0 || result.detailInserted > 0;
|
||||
|
||||
logger.info(`마스터-디테일 간단 모드 업로드 완료:`, {
|
||||
masterInserted: result.masterInserted,
|
||||
detailInserted: result.detailInserted,
|
||||
generatedKey: result.generatedKey,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
// 업로드 후 제어 실행 (단일 또는 다중)
|
||||
const flowsToExecute = afterUploadFlows && afterUploadFlows.length > 0
|
||||
? afterUploadFlows // 다중 제어
|
||||
: afterUploadFlowId
|
||||
? [{ flowId: afterUploadFlowId, order: 1 }] // 단일 (하위 호환성)
|
||||
: [];
|
||||
|
||||
if (flowsToExecute.length > 0 && result.success) {
|
||||
try {
|
||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
||||
|
||||
// 마스터 데이터 구성
|
||||
const masterData = {
|
||||
...masterFieldValues,
|
||||
[relation!.masterKeyColumn]: result.generatedKey,
|
||||
company_code: companyCode,
|
||||
};
|
||||
|
||||
const controlResults: any[] = [];
|
||||
|
||||
// 순서대로 제어 실행
|
||||
for (const flow of flowsToExecute.sort((a, b) => a.order - b.order)) {
|
||||
logger.info(`업로드 후 제어 실행: flowId=${flow.flowId}, order=${flow.order}`);
|
||||
logger.info(` 전달 데이터: 마스터 1건, 디테일 ${insertedDetailRows.length}건`);
|
||||
|
||||
// 🆕 삽입된 디테일 데이터를 sourceData로 전달 (성능 최적화)
|
||||
// - 전체 테이블 조회 대신 방금 INSERT한 데이터만 처리
|
||||
// - tableSource 노드가 context-data 모드일 때 이 데이터를 사용
|
||||
const controlResult = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flow.flowId),
|
||||
{
|
||||
sourceData: insertedDetailRows.length > 0 ? insertedDetailRows : [masterData],
|
||||
dataSourceType: "excelUpload", // 엑셀 업로드 데이터임을 명시
|
||||
buttonId: "excel-upload-button",
|
||||
screenId: screenId,
|
||||
userId: userId,
|
||||
companyCode: companyCode,
|
||||
formData: masterData,
|
||||
// 추가 컨텍스트: 마스터/디테일 정보
|
||||
masterData: masterData,
|
||||
detailData: insertedDetailRows,
|
||||
masterTable: relation!.masterTable,
|
||||
detailTable: relation!.detailTable,
|
||||
masterKeyColumn: relation!.masterKeyColumn,
|
||||
detailFkColumn: relation!.detailFkColumn,
|
||||
}
|
||||
);
|
||||
|
||||
controlResults.push({
|
||||
flowId: flow.flowId,
|
||||
order: flow.order,
|
||||
success: controlResult.success,
|
||||
message: controlResult.message,
|
||||
executedNodes: controlResult.nodes?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
result.controlResult = {
|
||||
success: controlResults.every(r => r.success),
|
||||
executedFlows: controlResults.length,
|
||||
results: controlResults,
|
||||
};
|
||||
|
||||
logger.info(`업로드 후 제어 실행 완료: ${controlResults.length}개 실행`, result.controlResult);
|
||||
} catch (controlError: any) {
|
||||
logger.error(`업로드 후 제어 실행 실패:`, controlError);
|
||||
result.controlResult = {
|
||||
success: false,
|
||||
message: `제어 실행 실패: ${controlError.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
result.errors.push(`트랜잭션 실패: ${error.message}`);
|
||||
logger.error(`마스터-디테일 간단 모드 업로드 실패:`, error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙으로 번호 생성 (기존 numberingRuleService 사용)
|
||||
*/
|
||||
private async generateNumberWithRule(
|
||||
client: any,
|
||||
ruleId: string,
|
||||
companyCode: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 기존 numberingRuleService를 사용하여 코드 할당
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
const generatedCode = await numberingRuleService.allocateCode(ruleId, companyCode);
|
||||
|
||||
logger.info(`채번 생성 (numberingRuleService): rule=${ruleId}, result=${generatedCode}`);
|
||||
|
||||
return generatedCode;
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 생성 실패: rule=${ruleId}, error=${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const masterDetailExcelService = new MasterDetailExcelService();
|
||||
|
||||
|
|
@ -2090,7 +2090,7 @@ export class MenuCopyService {
|
|||
menu.menu_url,
|
||||
menu.menu_desc,
|
||||
userId,
|
||||
'active', // 복제된 메뉴는 항상 활성화 상태
|
||||
menu.status,
|
||||
menu.system_name,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
menu.lang_key,
|
||||
|
|
|
|||
|
|
@ -1,969 +0,0 @@
|
|||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
/**
|
||||
* 메뉴-화면그룹 동기화 서비스
|
||||
*
|
||||
* 양방향 동기화:
|
||||
* 1. screen_groups → menu_info: 화면관리 폴더 구조를 메뉴로 동기화
|
||||
* 2. menu_info → screen_groups: 사용자 메뉴를 화면관리 폴더로 동기화
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
created: number;
|
||||
linked: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
details: SyncDetail[];
|
||||
}
|
||||
|
||||
interface SyncDetail {
|
||||
action: 'created' | 'linked' | 'skipped' | 'error';
|
||||
sourceName: string;
|
||||
sourceId: number | string;
|
||||
targetId?: number | string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 화면관리 → 메뉴 동기화
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* screen_groups를 menu_info로 동기화
|
||||
*
|
||||
* 로직:
|
||||
* 1. 해당 회사의 screen_groups 조회 (폴더 구조)
|
||||
* 2. 이미 menu_objid가 연결된 것은 제외
|
||||
* 3. 이름으로 기존 menu_info 매칭 시도
|
||||
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
||||
* - 매칭 안되면: menu_info에 새로 생성
|
||||
* 4. 계층 구조(parent) 유지
|
||||
*/
|
||||
export async function syncScreenGroupsToMenu(
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
created: 0,
|
||||
linked: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
details: [],
|
||||
};
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
logger.info("화면관리 → 메뉴 동기화 시작", { companyCode, userId });
|
||||
|
||||
// 1. 해당 회사의 screen_groups 조회 (아직 menu_objid가 없는 것)
|
||||
const screenGroupsQuery = `
|
||||
SELECT
|
||||
sg.id,
|
||||
sg.group_name,
|
||||
sg.group_code,
|
||||
sg.parent_group_id,
|
||||
sg.group_level,
|
||||
sg.display_order,
|
||||
sg.description,
|
||||
sg.icon,
|
||||
sg.menu_objid,
|
||||
-- 부모 그룹의 menu_objid도 조회 (계층 연결용)
|
||||
parent.menu_objid as parent_menu_objid
|
||||
FROM screen_groups sg
|
||||
LEFT JOIN screen_groups parent ON sg.parent_group_id = parent.id
|
||||
WHERE sg.company_code = $1
|
||||
ORDER BY sg.group_level ASC, sg.display_order ASC
|
||||
`;
|
||||
const screenGroupsResult = await client.query(screenGroupsQuery, [companyCode]);
|
||||
|
||||
// 2. 해당 회사의 기존 menu_info 조회 (사용자 메뉴, menu_type=1)
|
||||
// 경로 기반 매칭을 위해 부모 이름도 조회
|
||||
const existingMenusQuery = `
|
||||
SELECT
|
||||
m.objid,
|
||||
m.menu_name_kor,
|
||||
m.parent_obj_id,
|
||||
m.screen_group_id,
|
||||
p.menu_name_kor as parent_name
|
||||
FROM menu_info m
|
||||
LEFT JOIN menu_info p ON m.parent_obj_id = p.objid
|
||||
WHERE m.company_code = $1 AND m.menu_type = 1
|
||||
`;
|
||||
const existingMenusResult = await client.query(existingMenusQuery, [companyCode]);
|
||||
|
||||
// 경로(부모이름 > 이름) → 메뉴 매핑 (screen_group_id가 없는 것만)
|
||||
// 단순 이름 매칭도 유지 (하위 호환)
|
||||
const menuByPath: Map<string, any> = new Map();
|
||||
const menuByName: Map<string, any> = new Map();
|
||||
existingMenusResult.rows.forEach((menu: any) => {
|
||||
if (!menu.screen_group_id) {
|
||||
const menuName = menu.menu_name_kor?.trim().toLowerCase() || '';
|
||||
const parentName = menu.parent_name?.trim().toLowerCase() || '';
|
||||
const pathKey = parentName ? `${parentName}>${menuName}` : menuName;
|
||||
|
||||
menuByPath.set(pathKey, menu);
|
||||
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
||||
if (!menuByName.has(menuName)) {
|
||||
menuByName.set(menuName, menu);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 모든 메뉴의 objid 집합 (삭제 확인용)
|
||||
const existingMenuObjids = new Set(existingMenusResult.rows.map((m: any) => Number(m.objid)));
|
||||
|
||||
// 3. 사용자 메뉴의 루트 찾기 (parent_obj_id = 0인 사용자 메뉴)
|
||||
// 없으면 생성
|
||||
let userMenuRootObjid: number | null = null;
|
||||
const rootMenuQuery = `
|
||||
SELECT objid FROM menu_info
|
||||
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id = 0
|
||||
ORDER BY seq ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const rootMenuResult = await client.query(rootMenuQuery, [companyCode]);
|
||||
|
||||
if (rootMenuResult.rows.length > 0) {
|
||||
userMenuRootObjid = Number(rootMenuResult.rows[0].objid);
|
||||
} else {
|
||||
// 루트 메뉴가 없으면 생성
|
||||
const newObjid = Date.now();
|
||||
const createRootQuery = `
|
||||
INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status)
|
||||
VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active')
|
||||
RETURNING objid
|
||||
`;
|
||||
const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]);
|
||||
userMenuRootObjid = Number(createRootResult.rows[0].objid);
|
||||
logger.info("사용자 메뉴 루트 생성", { companyCode, objid: userMenuRootObjid });
|
||||
}
|
||||
|
||||
// 4. screen_groups ID → menu_objid 매핑 (순차 처리를 위해)
|
||||
const groupToMenuMap: Map<number, number> = new Map();
|
||||
|
||||
// screen_groups의 부모 이름 조회를 위한 매핑
|
||||
const groupIdToName: Map<number, string> = new Map();
|
||||
screenGroupsResult.rows.forEach((g: any) => {
|
||||
groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || '');
|
||||
});
|
||||
|
||||
// 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL)
|
||||
// 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치
|
||||
const topLevelCompanyFolderIds = new Set<number>();
|
||||
for (const group of screenGroupsResult.rows) {
|
||||
if (group.group_level === 0 && group.parent_group_id === null) {
|
||||
topLevelCompanyFolderIds.add(group.id);
|
||||
// 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용)
|
||||
groupToMenuMap.set(group.id, userMenuRootObjid!);
|
||||
logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 각 screen_group 처리
|
||||
for (const group of screenGroupsResult.rows) {
|
||||
const groupId = group.id;
|
||||
const groupName = group.group_name?.trim();
|
||||
const groupNameLower = groupName?.toLowerCase() || '';
|
||||
|
||||
// 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵
|
||||
if (topLevelCompanyFolderIds.has(groupId)) {
|
||||
result.skipped++;
|
||||
result.details.push({
|
||||
action: 'skipped',
|
||||
sourceName: groupName,
|
||||
sourceId: groupId,
|
||||
reason: '최상위 회사 폴더 (메뉴 생성 스킵)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인
|
||||
if (group.menu_objid) {
|
||||
const menuExists = existingMenuObjids.has(Number(group.menu_objid));
|
||||
|
||||
if (menuExists) {
|
||||
// 메뉴가 존재하면 스킵
|
||||
result.skipped++;
|
||||
result.details.push({
|
||||
action: 'skipped',
|
||||
sourceName: groupName,
|
||||
sourceId: groupId,
|
||||
targetId: group.menu_objid,
|
||||
reason: '이미 메뉴와 연결됨',
|
||||
});
|
||||
groupToMenuMap.set(groupId, Number(group.menu_objid));
|
||||
continue;
|
||||
} else {
|
||||
// 메뉴가 삭제되었으면 연결 해제하고 재생성
|
||||
logger.info("삭제된 메뉴 연결 해제", { groupId, deletedMenuObjid: group.menu_objid });
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET menu_objid = NULL, updated_date = NOW() WHERE id = $1`,
|
||||
[groupId]
|
||||
);
|
||||
// 계속 진행하여 재생성 또는 재연결
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 그룹 이름 조회 (경로 기반 매칭용)
|
||||
const parentGroupName = group.parent_group_id ? groupIdToName.get(group.parent_group_id) : '';
|
||||
const pathKey = parentGroupName ? `${parentGroupName}>${groupNameLower}` : groupNameLower;
|
||||
|
||||
// 경로로 기존 메뉴 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
||||
let matchedMenu = menuByPath.get(pathKey);
|
||||
if (!matchedMenu) {
|
||||
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
||||
matchedMenu = menuByName.get(groupNameLower);
|
||||
}
|
||||
|
||||
if (matchedMenu) {
|
||||
// 매칭된 메뉴와 연결
|
||||
const menuObjid = Number(matchedMenu.objid);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||
[menuObjid, groupId]
|
||||
);
|
||||
|
||||
// menu_info에 screen_group_id 업데이트
|
||||
await client.query(
|
||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||
[groupId, menuObjid]
|
||||
);
|
||||
|
||||
groupToMenuMap.set(groupId, menuObjid);
|
||||
result.linked++;
|
||||
result.details.push({
|
||||
action: 'linked',
|
||||
sourceName: groupName,
|
||||
sourceId: groupId,
|
||||
targetId: menuObjid,
|
||||
});
|
||||
|
||||
// 매칭된 메뉴는 Map에서 제거 (중복 매칭 방지)
|
||||
menuByPath.delete(pathKey);
|
||||
menuByName.delete(groupNameLower);
|
||||
|
||||
} else {
|
||||
// 새 메뉴 생성
|
||||
const newObjid = Date.now() + groupId; // 고유 ID 보장
|
||||
|
||||
// 부모 메뉴 objid 결정
|
||||
// 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수)
|
||||
let parentMenuObjid = userMenuRootObjid;
|
||||
if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) {
|
||||
// 현재 트랜잭션에서 생성된 부모 메뉴 사용
|
||||
parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!;
|
||||
} else if (group.parent_group_id && group.parent_menu_objid) {
|
||||
// 기존 parent_menu_objid가 실제로 존재하는지 확인
|
||||
const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid));
|
||||
if (parentMenuExists) {
|
||||
parentMenuObjid = Number(group.parent_menu_objid);
|
||||
}
|
||||
}
|
||||
|
||||
// 같은 부모 아래에서 가장 높은 seq 조회 후 +1
|
||||
let nextSeq = 1;
|
||||
const maxSeqQuery = `
|
||||
SELECT COALESCE(MAX(seq), 0) + 1 as next_seq
|
||||
FROM menu_info
|
||||
WHERE parent_obj_id = $1 AND company_code = $2 AND menu_type = 1
|
||||
`;
|
||||
const maxSeqResult = await client.query(maxSeqQuery, [parentMenuObjid, companyCode]);
|
||||
if (maxSeqResult.rows.length > 0) {
|
||||
nextSeq = parseInt(maxSeqResult.rows[0].next_seq) || 1;
|
||||
}
|
||||
|
||||
// menu_info에 삽입
|
||||
const insertMenuQuery = `
|
||||
INSERT INTO menu_info (
|
||||
objid, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc
|
||||
) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9)
|
||||
RETURNING objid
|
||||
`;
|
||||
await client.query(insertMenuQuery, [
|
||||
newObjid,
|
||||
parentMenuObjid,
|
||||
groupName,
|
||||
group.group_code || groupName,
|
||||
nextSeq,
|
||||
companyCode,
|
||||
userId,
|
||||
groupId,
|
||||
group.description || null,
|
||||
]);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||
[newObjid, groupId]
|
||||
);
|
||||
|
||||
groupToMenuMap.set(groupId, newObjid);
|
||||
result.created++;
|
||||
result.details.push({
|
||||
action: 'created',
|
||||
sourceName: groupName,
|
||||
sourceId: groupId,
|
||||
targetId: newObjid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info("화면관리 → 메뉴 동기화 완료", {
|
||||
companyCode,
|
||||
created: result.created,
|
||||
linked: result.linked,
|
||||
skipped: result.skipped
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error("화면관리 → 메뉴 동기화 실패", { companyCode, error: error.message });
|
||||
result.success = false;
|
||||
result.errors.push(error.message);
|
||||
return result;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 메뉴 → 화면관리 동기화
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* menu_info를 screen_groups로 동기화
|
||||
*
|
||||
* 로직:
|
||||
* 1. 해당 회사의 사용자 메뉴(menu_type=1) 조회
|
||||
* 2. 이미 screen_group_id가 연결된 것은 제외
|
||||
* 3. 이름으로 기존 screen_groups 매칭 시도
|
||||
* - 매칭되면: 양쪽에 연결 ID 업데이트
|
||||
* - 매칭 안되면: screen_groups에 새로 생성 (폴더로)
|
||||
* 4. 계층 구조(parent) 유지
|
||||
*/
|
||||
export async function syncMenuToScreenGroups(
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
created: 0,
|
||||
linked: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
details: [],
|
||||
};
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
logger.info("메뉴 → 화면관리 동기화 시작", { companyCode, userId });
|
||||
|
||||
// 0. 회사 이름 조회 (회사 폴더 찾기/생성용)
|
||||
const companyNameQuery = `SELECT company_name FROM company_mng WHERE company_code = $1`;
|
||||
const companyNameResult = await client.query(companyNameQuery, [companyCode]);
|
||||
const companyName = companyNameResult.rows[0]?.company_name || companyCode;
|
||||
|
||||
// 1. 해당 회사의 사용자 메뉴 조회 (menu_type=1)
|
||||
const menusQuery = `
|
||||
SELECT
|
||||
m.objid,
|
||||
m.menu_name_kor,
|
||||
m.menu_name_eng,
|
||||
m.parent_obj_id,
|
||||
m.seq,
|
||||
m.menu_url,
|
||||
m.menu_desc,
|
||||
m.screen_group_id,
|
||||
-- 부모 메뉴의 screen_group_id도 조회 (계층 연결용)
|
||||
parent.screen_group_id as parent_screen_group_id
|
||||
FROM menu_info m
|
||||
LEFT JOIN menu_info parent ON m.parent_obj_id = parent.objid
|
||||
WHERE m.company_code = $1 AND m.menu_type = 1
|
||||
ORDER BY
|
||||
CASE WHEN m.parent_obj_id = 0 THEN 0 ELSE 1 END,
|
||||
m.parent_obj_id,
|
||||
m.seq
|
||||
`;
|
||||
const menusResult = await client.query(menusQuery, [companyCode]);
|
||||
|
||||
// 2. 해당 회사의 기존 screen_groups 조회 (경로 기반 매칭을 위해 부모 이름도 조회)
|
||||
const existingGroupsQuery = `
|
||||
SELECT
|
||||
g.id,
|
||||
g.group_name,
|
||||
g.menu_objid,
|
||||
g.parent_group_id,
|
||||
p.group_name as parent_name
|
||||
FROM screen_groups g
|
||||
LEFT JOIN screen_groups p ON g.parent_group_id = p.id
|
||||
WHERE g.company_code = $1
|
||||
`;
|
||||
const existingGroupsResult = await client.query(existingGroupsQuery, [companyCode]);
|
||||
|
||||
// 경로(부모이름 > 이름) → 그룹 매핑 (menu_objid가 없는 것만)
|
||||
// 단순 이름 매칭도 유지 (하위 호환)
|
||||
const groupByPath: Map<string, any> = new Map();
|
||||
const groupByName: Map<string, any> = new Map();
|
||||
existingGroupsResult.rows.forEach((group: any) => {
|
||||
if (!group.menu_objid) {
|
||||
const groupName = group.group_name?.trim().toLowerCase() || '';
|
||||
const parentName = group.parent_name?.trim().toLowerCase() || '';
|
||||
const pathKey = parentName ? `${parentName}>${groupName}` : groupName;
|
||||
|
||||
groupByPath.set(pathKey, group);
|
||||
// 단순 이름 매핑은 첫 번째 것만 (중복 방지)
|
||||
if (!groupByName.has(groupName)) {
|
||||
groupByName.set(groupName, group);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 모든 그룹의 id 집합 (삭제 확인용)
|
||||
const existingGroupIds = new Set(existingGroupsResult.rows.map((g: any) => Number(g.id)));
|
||||
|
||||
// 3. 회사 폴더 찾기 또는 생성 (루트 레벨에 회사명으로 된 폴더)
|
||||
let companyFolderId: number | null = null;
|
||||
const companyFolderQuery = `
|
||||
SELECT id FROM screen_groups
|
||||
WHERE company_code = $1 AND parent_group_id IS NULL AND group_level = 0
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const companyFolderResult = await client.query(companyFolderQuery, [companyCode]);
|
||||
|
||||
if (companyFolderResult.rows.length > 0) {
|
||||
companyFolderId = companyFolderResult.rows[0].id;
|
||||
logger.info("회사 폴더 발견", { companyCode, companyFolderId, companyName });
|
||||
} else {
|
||||
// 회사 폴더가 없으면 생성
|
||||
// 루트 레벨에서 가장 높은 display_order 조회 후 +1
|
||||
let nextRootOrder = 1;
|
||||
const maxRootOrderQuery = `
|
||||
SELECT COALESCE(MAX(display_order), 0) + 1 as next_order
|
||||
FROM screen_groups
|
||||
WHERE parent_group_id IS NULL
|
||||
`;
|
||||
const maxRootOrderResult = await client.query(maxRootOrderQuery);
|
||||
if (maxRootOrderResult.rows.length > 0) {
|
||||
nextRootOrder = parseInt(maxRootOrderResult.rows[0].next_order) || 1;
|
||||
}
|
||||
|
||||
const createFolderQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, parent_group_id, group_level,
|
||||
display_order, company_code, writer, hierarchy_path
|
||||
) VALUES ($1, $2, NULL, 0, $3, $4, $5, '/')
|
||||
RETURNING id
|
||||
`;
|
||||
const createFolderResult = await client.query(createFolderQuery, [
|
||||
companyName,
|
||||
companyCode.toLowerCase(),
|
||||
nextRootOrder,
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
companyFolderId = createFolderResult.rows[0].id;
|
||||
|
||||
// hierarchy_path 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
||||
[`/${companyFolderId}/`, companyFolderId]
|
||||
);
|
||||
|
||||
logger.info("회사 폴더 생성", { companyCode, companyFolderId, companyName });
|
||||
}
|
||||
|
||||
// 4. menu_objid → screen_group_id 매핑 (순차 처리를 위해)
|
||||
const menuToGroupMap: Map<number, number> = new Map();
|
||||
|
||||
// 부모 메뉴 중 이미 screen_group_id가 있는 것 등록
|
||||
menusResult.rows.forEach((menu: any) => {
|
||||
if (menu.screen_group_id) {
|
||||
menuToGroupMap.set(Number(menu.objid), Number(menu.screen_group_id));
|
||||
}
|
||||
});
|
||||
|
||||
// 루트 메뉴(parent_obj_id = 0)의 objid 찾기 → 회사 폴더와 매핑
|
||||
let rootMenuObjid: number | null = null;
|
||||
for (const menu of menusResult.rows) {
|
||||
if (Number(menu.parent_obj_id) === 0) {
|
||||
rootMenuObjid = Number(menu.objid);
|
||||
// 루트 메뉴는 회사 폴더와 연결
|
||||
if (companyFolderId) {
|
||||
menuToGroupMap.set(rootMenuObjid, companyFolderId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 각 메뉴 처리
|
||||
for (const menu of menusResult.rows) {
|
||||
const menuObjid = Number(menu.objid);
|
||||
const menuName = menu.menu_name_kor?.trim();
|
||||
|
||||
// 루트 메뉴(parent_obj_id = 0)는 스킵 (이미 회사 폴더와 매핑됨)
|
||||
if (Number(menu.parent_obj_id) === 0) {
|
||||
result.skipped++;
|
||||
result.details.push({
|
||||
action: 'skipped',
|
||||
sourceName: menuName,
|
||||
sourceId: menuObjid,
|
||||
targetId: companyFolderId || undefined,
|
||||
reason: '루트 메뉴 → 회사 폴더와 매핑됨',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 연결된 경우 - 실제로 그룹이 존재하는지 확인
|
||||
if (menu.screen_group_id) {
|
||||
const groupExists = existingGroupIds.has(Number(menu.screen_group_id));
|
||||
|
||||
if (groupExists) {
|
||||
// 그룹이 존재하면 스킵
|
||||
result.skipped++;
|
||||
result.details.push({
|
||||
action: 'skipped',
|
||||
sourceName: menuName,
|
||||
sourceId: menuObjid,
|
||||
targetId: menu.screen_group_id,
|
||||
reason: '이미 화면그룹과 연결됨',
|
||||
});
|
||||
menuToGroupMap.set(menuObjid, Number(menu.screen_group_id));
|
||||
continue;
|
||||
} else {
|
||||
// 그룹이 삭제되었으면 연결 해제하고 재생성
|
||||
logger.info("삭제된 그룹 연결 해제", { menuObjid, deletedGroupId: menu.screen_group_id });
|
||||
await client.query(
|
||||
`UPDATE menu_info SET screen_group_id = NULL WHERE objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
// 계속 진행하여 재생성 또는 재연결
|
||||
}
|
||||
}
|
||||
|
||||
const menuNameLower = menuName?.toLowerCase() || '';
|
||||
|
||||
// 부모 메뉴 이름 조회 (경로 기반 매칭용)
|
||||
const parentMenu = menusResult.rows.find((m: any) => Number(m.objid) === Number(menu.parent_obj_id));
|
||||
const parentMenuName = parentMenu?.menu_name_kor?.trim().toLowerCase() || '';
|
||||
const pathKey = parentMenuName ? `${parentMenuName}>${menuNameLower}` : menuNameLower;
|
||||
|
||||
// 경로로 기존 그룹 매칭 시도 (우선순위: 경로 매칭 > 이름 매칭)
|
||||
let matchedGroup = groupByPath.get(pathKey);
|
||||
if (!matchedGroup) {
|
||||
// 경로 매칭 실패시 이름으로 시도 (하위 호환)
|
||||
matchedGroup = groupByName.get(menuNameLower);
|
||||
}
|
||||
|
||||
if (matchedGroup) {
|
||||
// 매칭된 그룹과 연결
|
||||
const groupId = Number(matchedGroup.id);
|
||||
|
||||
try {
|
||||
// menu_info에 screen_group_id 업데이트
|
||||
await client.query(
|
||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||
[groupId, menuObjid]
|
||||
);
|
||||
|
||||
// screen_groups에 menu_objid 업데이트
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET menu_objid = $1, updated_date = NOW() WHERE id = $2`,
|
||||
[menuObjid, groupId]
|
||||
);
|
||||
|
||||
menuToGroupMap.set(menuObjid, groupId);
|
||||
result.linked++;
|
||||
result.details.push({
|
||||
action: 'linked',
|
||||
sourceName: menuName,
|
||||
sourceId: menuObjid,
|
||||
targetId: groupId,
|
||||
});
|
||||
|
||||
// 매칭된 그룹은 Map에서 제거 (중복 매칭 방지)
|
||||
groupByPath.delete(pathKey);
|
||||
groupByName.delete(menuNameLower);
|
||||
} catch (linkError: any) {
|
||||
logger.error("그룹 연결 중 에러", { menuName, menuObjid, groupId, error: linkError.message, stack: linkError.stack });
|
||||
throw linkError;
|
||||
}
|
||||
|
||||
} else {
|
||||
// 새 screen_group 생성
|
||||
// 부모 그룹 ID 결정
|
||||
let parentGroupId: number | null = null;
|
||||
let groupLevel = 1; // 기본값은 1 (회사 폴더 아래)
|
||||
|
||||
// 우선순위 1: menuToGroupMap에서 부모 메뉴의 새 그룹 ID 조회 (같은 트랜잭션에서 생성된 것)
|
||||
if (menuToGroupMap.has(Number(menu.parent_obj_id))) {
|
||||
parentGroupId = menuToGroupMap.get(Number(menu.parent_obj_id))!;
|
||||
}
|
||||
// 우선순위 2: 부모 메뉴가 루트 메뉴면 회사 폴더 사용
|
||||
else if (Number(menu.parent_obj_id) === rootMenuObjid) {
|
||||
parentGroupId = companyFolderId;
|
||||
}
|
||||
// 우선순위 3: 부모 메뉴의 screen_group_id가 있고, 해당 그룹이 실제로 존재하면 사용
|
||||
else if (menu.parent_screen_group_id && existingGroupIds.has(Number(menu.parent_screen_group_id))) {
|
||||
parentGroupId = Number(menu.parent_screen_group_id);
|
||||
}
|
||||
|
||||
// 부모 그룹의 레벨 조회
|
||||
if (parentGroupId) {
|
||||
const parentLevelQuery = `SELECT group_level FROM screen_groups WHERE id = $1`;
|
||||
const parentLevelResult = await client.query(parentLevelQuery, [parentGroupId]);
|
||||
if (parentLevelResult.rows.length > 0) {
|
||||
groupLevel = (parentLevelResult.rows[0].group_level || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 같은 부모 아래에서 가장 높은 display_order 조회 후 +1
|
||||
let nextDisplayOrder = 1;
|
||||
const maxOrderQuery = parentGroupId
|
||||
? `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id = $1 AND company_code = $2`
|
||||
: `SELECT COALESCE(MAX(display_order), 0) + 1 as next_order FROM screen_groups WHERE parent_group_id IS NULL AND company_code = $1`;
|
||||
const maxOrderParams = parentGroupId ? [parentGroupId, companyCode] : [companyCode];
|
||||
const maxOrderResult = await client.query(maxOrderQuery, maxOrderParams);
|
||||
if (maxOrderResult.rows.length > 0) {
|
||||
nextDisplayOrder = parseInt(maxOrderResult.rows[0].next_order) || 1;
|
||||
}
|
||||
|
||||
// group_code 생성 (영문명 또는 이름 기반)
|
||||
const groupCode = (menu.menu_name_eng || menuName || 'group')
|
||||
.replace(/\s+/g, '_')
|
||||
.toLowerCase()
|
||||
.substring(0, 50);
|
||||
|
||||
// screen_groups에 삽입
|
||||
const insertGroupQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, parent_group_id, group_level,
|
||||
display_order, company_code, writer, menu_objid, description
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
let newGroupId: number;
|
||||
try {
|
||||
logger.info("새 그룹 생성 시도", {
|
||||
menuName,
|
||||
menuObjid,
|
||||
groupCode: groupCode + '_' + menuObjid,
|
||||
parentGroupId,
|
||||
groupLevel,
|
||||
nextDisplayOrder,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const insertResult = await client.query(insertGroupQuery, [
|
||||
menuName,
|
||||
groupCode + '_' + menuObjid, // 고유성 보장
|
||||
parentGroupId,
|
||||
groupLevel,
|
||||
nextDisplayOrder,
|
||||
companyCode,
|
||||
userId,
|
||||
menuObjid,
|
||||
menu.menu_desc || null,
|
||||
]);
|
||||
|
||||
newGroupId = insertResult.rows[0].id;
|
||||
} catch (insertError: any) {
|
||||
logger.error("그룹 생성 중 에러", {
|
||||
menuName,
|
||||
menuObjid,
|
||||
parentGroupId,
|
||||
groupLevel,
|
||||
error: insertError.message,
|
||||
stack: insertError.stack,
|
||||
code: insertError.code,
|
||||
detail: insertError.detail,
|
||||
});
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
// hierarchy_path 업데이트
|
||||
let hierarchyPath = `/${newGroupId}/`;
|
||||
if (parentGroupId) {
|
||||
const parentPathQuery = `SELECT hierarchy_path FROM screen_groups WHERE id = $1`;
|
||||
const parentPathResult = await client.query(parentPathQuery, [parentGroupId]);
|
||||
if (parentPathResult.rows.length > 0 && parentPathResult.rows[0].hierarchy_path) {
|
||||
hierarchyPath = `${parentPathResult.rows[0].hierarchy_path}${newGroupId}/`.replace('//', '/');
|
||||
}
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE screen_groups SET hierarchy_path = $1 WHERE id = $2`,
|
||||
[hierarchyPath, newGroupId]
|
||||
);
|
||||
|
||||
// menu_info에 screen_group_id 업데이트
|
||||
await client.query(
|
||||
`UPDATE menu_info SET screen_group_id = $1 WHERE objid = $2`,
|
||||
[newGroupId, menuObjid]
|
||||
);
|
||||
|
||||
menuToGroupMap.set(menuObjid, newGroupId);
|
||||
result.created++;
|
||||
result.details.push({
|
||||
action: 'created',
|
||||
sourceName: menuName,
|
||||
sourceId: menuObjid,
|
||||
targetId: newGroupId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info("메뉴 → 화면관리 동기화 완료", {
|
||||
companyCode,
|
||||
created: result.created,
|
||||
linked: result.linked,
|
||||
skipped: result.skipped
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error("메뉴 → 화면관리 동기화 실패", {
|
||||
companyCode,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
code: error.code,
|
||||
detail: error.detail,
|
||||
});
|
||||
result.success = false;
|
||||
result.errors.push(error.message);
|
||||
return result;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 동기화 상태 조회
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 동기화 상태 조회
|
||||
*
|
||||
* - 연결된 항목 수
|
||||
* - 연결 안 된 항목 수
|
||||
* - 양방향 비교
|
||||
*/
|
||||
export async function getSyncStatus(companyCode: string): Promise<{
|
||||
screenGroups: { total: number; linked: number; unlinked: number };
|
||||
menuItems: { total: number; linked: number; unlinked: number };
|
||||
potentialMatches: Array<{ menuName: string; groupName: string; similarity: string }>;
|
||||
}> {
|
||||
// screen_groups 상태
|
||||
const sgQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(menu_objid) as linked
|
||||
FROM screen_groups
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
const sgResult = await pool.query(sgQuery, [companyCode]);
|
||||
|
||||
// menu_info 상태 (사용자 메뉴만, 루트 제외)
|
||||
const menuQuery = `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(screen_group_id) as linked
|
||||
FROM menu_info
|
||||
WHERE company_code = $1 AND menu_type = 1 AND parent_obj_id != 0
|
||||
`;
|
||||
const menuResult = await pool.query(menuQuery, [companyCode]);
|
||||
|
||||
// 이름이 같은 잠재적 매칭 후보 조회
|
||||
const matchQuery = `
|
||||
SELECT
|
||||
m.menu_name_kor as menu_name,
|
||||
sg.group_name
|
||||
FROM menu_info m
|
||||
JOIN screen_groups sg ON LOWER(TRIM(m.menu_name_kor)) = LOWER(TRIM(sg.group_name))
|
||||
WHERE m.company_code = $1
|
||||
AND sg.company_code = $1
|
||||
AND m.menu_type = 1
|
||||
AND m.screen_group_id IS NULL
|
||||
AND sg.menu_objid IS NULL
|
||||
LIMIT 10
|
||||
`;
|
||||
const matchResult = await pool.query(matchQuery, [companyCode]);
|
||||
|
||||
const sgTotal = parseInt(sgResult.rows[0].total);
|
||||
const sgLinked = parseInt(sgResult.rows[0].linked);
|
||||
const menuTotal = parseInt(menuResult.rows[0].total);
|
||||
const menuLinked = parseInt(menuResult.rows[0].linked);
|
||||
|
||||
return {
|
||||
screenGroups: {
|
||||
total: sgTotal,
|
||||
linked: sgLinked,
|
||||
unlinked: sgTotal - sgLinked,
|
||||
},
|
||||
menuItems: {
|
||||
total: menuTotal,
|
||||
linked: menuLinked,
|
||||
unlinked: menuTotal - menuLinked,
|
||||
},
|
||||
potentialMatches: matchResult.rows.map((row: any) => ({
|
||||
menuName: row.menu_name,
|
||||
groupName: row.group_name,
|
||||
similarity: 'exact',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// 전체 동기화 (모든 회사)
|
||||
// ============================================================
|
||||
|
||||
interface AllCompaniesSyncResult {
|
||||
success: boolean;
|
||||
totalCompanies: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
results: Array<{
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
direction: 'screens-to-menus' | 'menus-to-screens';
|
||||
created: number;
|
||||
linked: number;
|
||||
skipped: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 회사에 대해 양방향 동기화 수행
|
||||
*
|
||||
* 로직:
|
||||
* 1. 모든 회사 조회
|
||||
* 2. 각 회사별로 양방향 동기화 수행
|
||||
* - 화면관리 → 메뉴 동기화
|
||||
* - 메뉴 → 화면관리 동기화
|
||||
* 3. 결과 집계
|
||||
*/
|
||||
export async function syncAllCompanies(
|
||||
userId: string
|
||||
): Promise<AllCompaniesSyncResult> {
|
||||
const result: AllCompaniesSyncResult = {
|
||||
success: true,
|
||||
totalCompanies: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
results: [],
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info("전체 동기화 시작", { userId });
|
||||
|
||||
// 모든 회사 조회 (최고 관리자 전용 회사 제외)
|
||||
const companiesQuery = `
|
||||
SELECT company_code, company_name
|
||||
FROM company_mng
|
||||
WHERE company_code != '*'
|
||||
ORDER BY company_name
|
||||
`;
|
||||
const companiesResult = await pool.query(companiesQuery);
|
||||
|
||||
result.totalCompanies = companiesResult.rows.length;
|
||||
|
||||
// 각 회사별로 양방향 동기화
|
||||
for (const company of companiesResult.rows) {
|
||||
const companyCode = company.company_code;
|
||||
const companyName = company.company_name;
|
||||
|
||||
try {
|
||||
// 1. 화면관리 → 메뉴 동기화
|
||||
const screensToMenusResult = await syncScreenGroupsToMenu(companyCode, userId);
|
||||
result.results.push({
|
||||
companyCode,
|
||||
companyName,
|
||||
direction: 'screens-to-menus',
|
||||
created: screensToMenusResult.created,
|
||||
linked: screensToMenusResult.linked,
|
||||
skipped: screensToMenusResult.skipped,
|
||||
success: screensToMenusResult.success,
|
||||
error: screensToMenusResult.errors.length > 0 ? screensToMenusResult.errors.join(', ') : undefined,
|
||||
});
|
||||
|
||||
// 2. 메뉴 → 화면관리 동기화
|
||||
const menusToScreensResult = await syncMenuToScreenGroups(companyCode, userId);
|
||||
result.results.push({
|
||||
companyCode,
|
||||
companyName,
|
||||
direction: 'menus-to-screens',
|
||||
created: menusToScreensResult.created,
|
||||
linked: menusToScreensResult.linked,
|
||||
skipped: menusToScreensResult.skipped,
|
||||
success: menusToScreensResult.success,
|
||||
error: menusToScreensResult.errors.length > 0 ? menusToScreensResult.errors.join(', ') : undefined,
|
||||
});
|
||||
|
||||
if (screensToMenusResult.success && menusToScreensResult.success) {
|
||||
result.successCount++;
|
||||
} else {
|
||||
result.failedCount++;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("회사 동기화 실패", { companyCode, companyName, error: error.message });
|
||||
result.results.push({
|
||||
companyCode,
|
||||
companyName,
|
||||
direction: 'screens-to-menus',
|
||||
created: 0,
|
||||
linked: 0,
|
||||
skipped: 0,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
result.failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("전체 동기화 완료", {
|
||||
totalCompanies: result.totalCompanies,
|
||||
successCount: result.successCount,
|
||||
failedCount: result.failedCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error("전체 동기화 실패", { error: error.message });
|
||||
result.success = false;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -969,56 +969,21 @@ export class NodeFlowExecutionService {
|
|||
const insertedData = { ...data };
|
||||
|
||||
console.log("🗺️ 필드 매핑 처리 중...");
|
||||
|
||||
// 🔥 채번 규칙 서비스 동적 import
|
||||
const { numberingRuleService } = await import("./numberingRuleService");
|
||||
|
||||
for (const mapping of fieldMappings) {
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
fields.push(mapping.targetField);
|
||||
let value: any;
|
||||
|
||||
// 🔥 값 생성 유형에 따른 처리
|
||||
const valueType = mapping.valueType || (mapping.staticValue !== undefined ? "static" : "source");
|
||||
|
||||
if (valueType === "autoGenerate" && mapping.numberingRuleId) {
|
||||
// 자동 생성 (채번 규칙)
|
||||
const companyCode = context.buttonContext?.companyCode || "*";
|
||||
try {
|
||||
value = await numberingRuleService.allocateCode(
|
||||
mapping.numberingRuleId,
|
||||
companyCode
|
||||
);
|
||||
console.log(
|
||||
` 🔢 자동 생성(채번): ${mapping.targetField} = ${value} (규칙: ${mapping.numberingRuleId})`
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error(`채번 규칙 적용 실패: ${error.message}`);
|
||||
console.error(
|
||||
` ❌ 채번 실패 → ${mapping.targetField}: ${error.message}`
|
||||
);
|
||||
throw new Error(
|
||||
`채번 규칙 '${mapping.numberingRuleName || mapping.numberingRuleId}' 적용 실패: ${error.message}`
|
||||
);
|
||||
}
|
||||
} else if (valueType === "static" || mapping.staticValue !== undefined) {
|
||||
// 고정값
|
||||
value = mapping.staticValue;
|
||||
console.log(
|
||||
` 📌 고정값: ${mapping.targetField} = ${value}`
|
||||
);
|
||||
} else {
|
||||
// 소스 필드
|
||||
value = data[mapping.sourceField];
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
}
|
||||
|
||||
const value =
|
||||
mapping.staticValue !== undefined
|
||||
? mapping.staticValue
|
||||
: data[mapping.sourceField];
|
||||
|
||||
console.log(
|
||||
` ${mapping.sourceField} → ${mapping.targetField}: ${value === undefined ? "❌ undefined" : "✅ " + value}`
|
||||
);
|
||||
values.push(value);
|
||||
|
||||
// 🔥 삽입된 값을 데이터에 반영
|
||||
insertedData[mapping.targetField] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||
const hasWriterMapping = fieldMappings.some(
|
||||
|
|
@ -1563,24 +1528,16 @@ export class NodeFlowExecutionService {
|
|||
}
|
||||
});
|
||||
|
||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||
let finalWhereConditions: any[];
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||
finalWhereConditions = whereConditions;
|
||||
} else {
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
}
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
finalWhereConditions,
|
||||
enhancedWhereConditions,
|
||||
data,
|
||||
paramIndex
|
||||
);
|
||||
|
|
@ -1950,30 +1907,22 @@ export class NodeFlowExecutionService {
|
|||
return deletedDataArray;
|
||||
}
|
||||
|
||||
// 🆕 context-data 모드: 개별 삭제
|
||||
// 🆕 context-data 모드: 개별 삭제 (PK 자동 추가)
|
||||
console.log("🎯 context-data 모드: 개별 삭제 시작");
|
||||
|
||||
for (const data of dataArray) {
|
||||
console.log("🔍 WHERE 조건 처리 중...");
|
||||
|
||||
// 🔑 Primary Key 자동 추가 여부 결정:
|
||||
// whereConditions가 명시적으로 설정되어 있으면 PK 자동 추가를 하지 않음
|
||||
// (사용자가 직접 조건을 설정한 경우 의도를 존중)
|
||||
let finalWhereConditions: any[];
|
||||
if (whereConditions && whereConditions.length > 0) {
|
||||
console.log("📋 사용자 정의 WHERE 조건 사용 (PK 자동 추가 안 함)");
|
||||
finalWhereConditions = whereConditions;
|
||||
} else {
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
finalWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
}
|
||||
// 🔑 Primary Key 자동 추가 (context-data 모드)
|
||||
console.log("🔑 context-data 모드: Primary Key 자동 추가");
|
||||
const enhancedWhereConditions = await this.enhanceWhereConditionsWithPK(
|
||||
whereConditions,
|
||||
data,
|
||||
targetTable
|
||||
);
|
||||
|
||||
const whereResult = this.buildWhereClause(
|
||||
finalWhereConditions,
|
||||
enhancedWhereConditions,
|
||||
data,
|
||||
1
|
||||
);
|
||||
|
|
@ -2771,48 +2720,28 @@ export class NodeFlowExecutionService {
|
|||
const trueData: any[] = [];
|
||||
const falseData: any[] = [];
|
||||
|
||||
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||
for (const item of inputData) {
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
inputData.forEach((item: any) => {
|
||||
const results = conditions.map((condition: any) => {
|
||||
const fieldValue = item[condition.field];
|
||||
|
||||
// EXISTS 계열 연산자 처리
|
||||
if (
|
||||
condition.operator === "EXISTS_IN" ||
|
||||
condition.operator === "NOT_EXISTS_IN"
|
||||
) {
|
||||
const existsResult = await this.evaluateExistsCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
condition.lookupTable,
|
||||
condition.lookupField,
|
||||
context.buttonContext?.companyCode
|
||||
);
|
||||
results.push(existsResult);
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
logger.info(
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
|
||||
results.push(
|
||||
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.evaluateCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
compareValue
|
||||
);
|
||||
});
|
||||
|
||||
const result =
|
||||
logic === "OR"
|
||||
|
|
@ -2824,7 +2753,7 @@ export class NodeFlowExecutionService {
|
|||
} else {
|
||||
falseData.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
||||
|
|
@ -2839,46 +2768,27 @@ export class NodeFlowExecutionService {
|
|||
}
|
||||
|
||||
// 단일 객체인 경우
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
const results = conditions.map((condition: any) => {
|
||||
const fieldValue = inputData[condition.field];
|
||||
|
||||
// EXISTS 계열 연산자 처리
|
||||
if (
|
||||
condition.operator === "EXISTS_IN" ||
|
||||
condition.operator === "NOT_EXISTS_IN"
|
||||
) {
|
||||
const existsResult = await this.evaluateExistsCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
condition.lookupTable,
|
||||
condition.lookupField,
|
||||
context.buttonContext?.companyCode
|
||||
);
|
||||
results.push(existsResult);
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
logger.info(
|
||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
|
||||
results.push(
|
||||
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||
logger.info(
|
||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.evaluateCondition(
|
||||
fieldValue,
|
||||
condition.operator,
|
||||
compareValue
|
||||
);
|
||||
});
|
||||
|
||||
const result =
|
||||
logic === "OR"
|
||||
|
|
@ -2887,7 +2797,7 @@ export class NodeFlowExecutionService {
|
|||
|
||||
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
||||
|
||||
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
||||
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
||||
return {
|
||||
|
|
@ -2898,69 +2808,6 @@ export class NodeFlowExecutionService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
*/
|
||||
private static async evaluateExistsCondition(
|
||||
fieldValue: any,
|
||||
operator: string,
|
||||
lookupTable: string,
|
||||
lookupField: string,
|
||||
companyCode?: string
|
||||
): Promise<boolean> {
|
||||
if (!lookupTable || !lookupField) {
|
||||
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||
logger.info(
|
||||
`⚠️ EXISTS 조건: 필드값이 비어있어 FALSE 반환 (빈 값은 조건 검사하지 않음)`
|
||||
);
|
||||
// 값이 비어있으면 조건 검사 자체가 무의미하므로 항상 false 반환
|
||||
// 이렇게 하면 빈 값으로 인한 의도치 않은 INSERT/UPDATE/DELETE가 방지됨
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 멀티테넌시: company_code 필터 적용 여부 확인
|
||||
// company_mng 테이블은 제외
|
||||
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
|
||||
|
||||
let sql: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode) {
|
||||
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
|
||||
params = [fieldValue, companyCode];
|
||||
} else {
|
||||
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
|
||||
params = [fieldValue];
|
||||
}
|
||||
|
||||
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
|
||||
|
||||
const result = await query(sql, params);
|
||||
const existsInTable = result[0]?.exists_result === true;
|
||||
|
||||
logger.info(
|
||||
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
|
||||
);
|
||||
|
||||
// EXISTS_IN: 존재하면 true
|
||||
// NOT_EXISTS_IN: 존재하지 않으면 true
|
||||
if (operator === "EXISTS_IN") {
|
||||
return existsInTable;
|
||||
} else {
|
||||
return !existsInTable;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 생성
|
||||
*/
|
||||
|
|
@ -4446,8 +4293,6 @@ export class NodeFlowExecutionService {
|
|||
|
||||
/**
|
||||
* 산술 연산 계산
|
||||
* 다중 연산 지원: (leftOperand operator rightOperand) 이후 additionalOperations 순차 적용
|
||||
* 예: (width * height) / 1000000 * qty
|
||||
*/
|
||||
private static evaluateArithmetic(
|
||||
arithmetic: any,
|
||||
|
|
@ -4474,67 +4319,27 @@ export class NodeFlowExecutionService {
|
|||
const leftNum = Number(left) || 0;
|
||||
const rightNum = Number(right) || 0;
|
||||
|
||||
// 기본 연산 수행
|
||||
let result = this.applyOperator(leftNum, arithmetic.operator, rightNum);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추가 연산 처리 (다중 연산 지원)
|
||||
if (arithmetic.additionalOperations && Array.isArray(arithmetic.additionalOperations)) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const operandValue = this.getOperandValue(
|
||||
addOp.operand,
|
||||
sourceRow,
|
||||
targetRow,
|
||||
resultValues
|
||||
);
|
||||
const operandNum = Number(operandValue) || 0;
|
||||
|
||||
result = this.applyOperator(result, addOp.operator, operandNum);
|
||||
|
||||
if (result === null) {
|
||||
logger.warn(`⚠️ 추가 연산 실패: ${addOp.operator}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(` 추가 연산: ${addOp.operator} ${operandNum} = ${result}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 연산자 적용
|
||||
*/
|
||||
private static applyOperator(
|
||||
left: number,
|
||||
operator: string,
|
||||
right: number
|
||||
): number | null {
|
||||
switch (operator) {
|
||||
switch (arithmetic.operator) {
|
||||
case "+":
|
||||
return left + right;
|
||||
return leftNum + rightNum;
|
||||
case "-":
|
||||
return left - right;
|
||||
return leftNum - rightNum;
|
||||
case "*":
|
||||
return left * right;
|
||||
return leftNum * rightNum;
|
||||
case "/":
|
||||
if (right === 0) {
|
||||
if (rightNum === 0) {
|
||||
logger.warn(`⚠️ 0으로 나누기 시도`);
|
||||
return null;
|
||||
}
|
||||
return left / right;
|
||||
return leftNum / rightNum;
|
||||
case "%":
|
||||
if (right === 0) {
|
||||
if (rightNum === 0) {
|
||||
logger.warn(`⚠️ 0으로 나머지 연산 시도`);
|
||||
return null;
|
||||
}
|
||||
return left % right;
|
||||
return leftNum % rightNum;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 연산자: ${operator}`);
|
||||
throw new Error(`지원하지 않는 연산자: ${arithmetic.operator}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2597,10 +2597,10 @@ export class ScreenManagementService {
|
|||
// 없으면 원본과 같은 회사에 복사
|
||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||
|
||||
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
|
||||
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
||||
const existingScreens = await client.query<any>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
WHERE screen_code = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[copyData.screenCode, targetCompanyCode]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -187,68 +187,71 @@ class TableCategoryValueService {
|
|||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
}
|
||||
|
||||
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
|
||||
// 2. 카테고리 값 조회 (형제 메뉴 포함)
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
const baseSelect = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 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 = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
logger.info("최고 관리자 카테고리 값 조회");
|
||||
} 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 = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
menu_objid AS "menuObjid",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
logger.info("회사별 카테고리 값 조회", { companyCode });
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
|
|
@ -1395,220 +1398,6 @@ class TableCategoryValueService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용)
|
||||
*
|
||||
* 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [columnName]: { [label]: code } } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelToCodeMapping(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
try {
|
||||
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
|
||||
const categoryColumnsQuery = `
|
||||
SELECT column_name
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'category'
|
||||
`;
|
||||
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
|
||||
|
||||
if (categoryColumnsResult.rows.length === 0) {
|
||||
logger.info("카테고리 타입 컬럼 없음", { tableName });
|
||||
return {};
|
||||
}
|
||||
|
||||
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
|
||||
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
|
||||
|
||||
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
|
||||
for (const columnName of categoryColumns) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
`;
|
||||
params = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND is_active = true
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
`;
|
||||
params = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const valuesResult = await pool.query(query, params);
|
||||
|
||||
// { [label]: code } 형태로 변환
|
||||
const labelToCodeMap: Record<string, string> = {};
|
||||
for (const row of valuesResult.rows) {
|
||||
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
|
||||
labelToCodeMap[row.value_label] = row.value_code;
|
||||
// 소문자 키도 추가 (대소문자 무시 검색용)
|
||||
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
|
||||
}
|
||||
|
||||
if (Object.keys(labelToCodeMap).length > 0) {
|
||||
result[columnName] = labelToCodeMap;
|
||||
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
|
||||
tableName,
|
||||
columnCount: Object.keys(result).length
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터의 카테고리 라벨 값을 코드 값으로 변환
|
||||
*
|
||||
* 엑셀 업로드 등에서 사용자가 입력한 라벨 값을 DB 저장용 코드 값으로 변환
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param companyCode - 회사 코드
|
||||
* @param data - 변환할 데이터 객체
|
||||
* @returns 라벨이 코드로 변환된 데이터 객체
|
||||
*/
|
||||
async convertCategoryLabelsToCodesForData(
|
||||
tableName: string,
|
||||
companyCode: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
|
||||
try {
|
||||
// 라벨→코드 매핑 조회
|
||||
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
|
||||
|
||||
if (Object.keys(labelToCodeMapping).length === 0) {
|
||||
// 카테고리 컬럼 없음
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
|
||||
const convertedData = { ...data };
|
||||
const conversions: Array<{ column: string; label: string; code: string }> = [];
|
||||
|
||||
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
|
||||
const value = data[columnName];
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const stringValue = String(value).trim();
|
||||
|
||||
// 다중 값 확인 (쉼표로 구분된 경우)
|
||||
if (stringValue.includes(",")) {
|
||||
// 다중 카테고리 값 처리
|
||||
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
|
||||
const convertedCodes: string[] = [];
|
||||
let allConverted = true;
|
||||
|
||||
for (const label of labels) {
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[label];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[label.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
convertedCodes.push(matchedCode);
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: label,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
|
||||
if (isAlreadyCode) {
|
||||
// 이미 코드값이면 그대로 사용
|
||||
convertedCodes.push(label);
|
||||
} else {
|
||||
// 라벨도 코드도 아니면 원래 값 유지
|
||||
convertedCodes.push(label);
|
||||
allConverted = false;
|
||||
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 변환된 코드들을 쉼표로 합쳐서 저장
|
||||
convertedData[columnName] = convertedCodes.join(",");
|
||||
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
|
||||
} else {
|
||||
// 단일 값 처리
|
||||
// 정확한 라벨 매칭 시도
|
||||
let matchedCode = labelCodeMap[stringValue];
|
||||
|
||||
// 대소문자 무시 매칭
|
||||
if (!matchedCode) {
|
||||
matchedCode = labelCodeMap[stringValue.toLowerCase()];
|
||||
}
|
||||
|
||||
if (matchedCode) {
|
||||
// 라벨 값을 코드 값으로 변환
|
||||
convertedData[columnName] = matchedCode;
|
||||
conversions.push({
|
||||
column: columnName,
|
||||
label: stringValue,
|
||||
code: matchedCode,
|
||||
});
|
||||
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
|
||||
} else {
|
||||
// 이미 코드값인지 확인 (역방향 확인)
|
||||
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
|
||||
if (!isAlreadyCode) {
|
||||
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
|
||||
}
|
||||
// 변환 없이 원래 값 유지
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨→코드 변환 완료`, {
|
||||
tableName,
|
||||
conversionCount: conversions.length,
|
||||
conversions,
|
||||
});
|
||||
|
||||
return { convertedData, conversions };
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
|
||||
// 실패 시 원본 데이터 반환
|
||||
return { convertedData: data, conversions: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
|
|
|||
|
|
@ -1306,48 +1306,6 @@ export class TableManagementService {
|
|||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
|
||||
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// 배열의 각 값에 대해 OR 조건으로 검색
|
||||
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
|
||||
// 각 값을 LIKE 또는 = 조건으로 처리
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
value.forEach((v: any, idx: number) => {
|
||||
const safeValue = String(v).trim();
|
||||
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
||||
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
|
||||
// - 정확히 "2"
|
||||
// - "2," 로 시작
|
||||
// - ",2" 로 끝남
|
||||
// - ",2," 중간에 포함
|
||||
const paramBase = paramIndex + idx * 4;
|
||||
conditions.push(`(
|
||||
${columnName}::text = $${paramBase} OR
|
||||
${columnName}::text LIKE $${paramBase + 1} OR
|
||||
${columnName}::text LIKE $${paramBase + 2} OR
|
||||
${columnName}::text LIKE $${paramBase + 3}
|
||||
)`);
|
||||
values.push(
|
||||
safeValue,
|
||||
`${safeValue},%`,
|
||||
`%,${safeValue}`,
|
||||
`%,${safeValue},%`
|
||||
);
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`
|
||||
);
|
||||
return {
|
||||
whereClause: `(${conditions.join(" OR ")})`,
|
||||
values,
|
||||
paramCount: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(
|
||||
|
|
@ -1779,29 +1737,21 @@ export class TableManagementService {
|
|||
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||
const referenceTable = entityTypeInfo.referenceTable;
|
||||
|
||||
|
||||
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
||||
let displayColumn = entityTypeInfo.displayColumn;
|
||||
if (
|
||||
!displayColumn ||
|
||||
displayColumn === "none" ||
|
||||
displayColumn === ""
|
||||
) {
|
||||
displayColumn = await this.findDisplayColumnForTable(
|
||||
referenceTable,
|
||||
referenceColumn
|
||||
);
|
||||
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
|
||||
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
|
||||
logger.info(
|
||||
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
||||
);
|
||||
}
|
||||
|
||||
// 참조 테이블의 표시 컬럼으로 검색
|
||||
// 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정
|
||||
return {
|
||||
whereClause: `EXISTS (
|
||||
SELECT 1 FROM ${referenceTable} ref
|
||||
WHERE ref.${referenceColumn} = main.${columnName}
|
||||
WHERE ref.${referenceColumn} = ${columnName}
|
||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||
)`,
|
||||
values: [`%${value}%`],
|
||||
|
|
@ -2165,14 +2115,14 @@ export class TableManagementService {
|
|||
// 안전한 테이블명 검증
|
||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
|
||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
|
||||
// 전체 개수 조회
|
||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
|
||||
const countResult = await query<any>(countQuery, searchValues);
|
||||
const total = parseInt(countResult[0].count);
|
||||
|
||||
// 데이터 조회 (main 별칭 추가)
|
||||
// 데이터 조회
|
||||
const dataQuery = `
|
||||
SELECT main.* FROM ${safeTableName} main
|
||||
SELECT * FROM ${safeTableName}
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
|
|
@ -2509,7 +2459,7 @@ export class TableManagementService {
|
|||
skippedColumns.push(column);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const dataType = columnTypeMap.get(column) || "text";
|
||||
setConditions.push(
|
||||
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
|
||||
|
|
@ -2521,9 +2471,7 @@ export class TableManagementService {
|
|||
});
|
||||
|
||||
if (skippedColumns.length > 0) {
|
||||
logger.info(
|
||||
`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`
|
||||
);
|
||||
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
|
||||
}
|
||||
|
||||
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
|
||||
|
|
@ -2728,12 +2676,6 @@ export class TableManagementService {
|
|||
filterColumn?: string;
|
||||
filterValue?: any;
|
||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
deduplication?: {
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
}; // 🆕 중복 제거 설정
|
||||
}
|
||||
): Promise<EntityJoinResponse> {
|
||||
const startTime = Date.now();
|
||||
|
|
@ -2784,74 +2726,33 @@ export class TableManagementService {
|
|||
);
|
||||
|
||||
for (const additionalColumn of options.additionalJoinColumns) {
|
||||
// 🔍 1차: sourceColumn을 기준으로 기존 조인 설정 찾기
|
||||
let baseJoinConfig = joinConfigs.find(
|
||||
// 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
|
||||
const baseJoinConfig = joinConfigs.find(
|
||||
(config) => config.sourceColumn === additionalColumn.sourceColumn
|
||||
);
|
||||
|
||||
// 🔍 2차: referenceTable을 기준으로 찾기 (프론트엔드가 customer_mng.customer_name 같은 형식을 요청할 때)
|
||||
// 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응
|
||||
if (!baseJoinConfig && (additionalColumn as any).referenceTable) {
|
||||
baseJoinConfig = joinConfigs.find(
|
||||
(config) =>
|
||||
config.referenceTable ===
|
||||
(additionalColumn as any).referenceTable
|
||||
);
|
||||
if (baseJoinConfig) {
|
||||
logger.info(
|
||||
`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (baseJoinConfig) {
|
||||
// joinAlias에서 실제 컬럼명 추출
|
||||
const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id)
|
||||
const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name)
|
||||
|
||||
// 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리
|
||||
// customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거)
|
||||
// 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거)
|
||||
let actualColumnName: string;
|
||||
|
||||
// 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출
|
||||
const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id)
|
||||
if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) {
|
||||
// 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거
|
||||
actualColumnName = originalJoinAlias.replace(
|
||||
`${frontendSourceColumn}_`,
|
||||
""
|
||||
);
|
||||
} else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) {
|
||||
// 실제 소스 컬럼으로 시작하면 그 부분 제거
|
||||
actualColumnName = originalJoinAlias.replace(
|
||||
`${sourceColumn}_`,
|
||||
""
|
||||
);
|
||||
} else {
|
||||
// 어느 것도 아니면 원본 사용
|
||||
actualColumnName = originalJoinAlias;
|
||||
}
|
||||
|
||||
// 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반)
|
||||
const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`;
|
||||
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
|
||||
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명
|
||||
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
|
||||
const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
|
||||
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
|
||||
|
||||
logger.info(`🔍 조인 컬럼 상세 분석:`, {
|
||||
sourceColumn,
|
||||
frontendSourceColumn,
|
||||
originalJoinAlias,
|
||||
correctedJoinAlias,
|
||||
joinAlias,
|
||||
actualColumnName,
|
||||
referenceTable: (additionalColumn as any).referenceTable,
|
||||
referenceTable: additionalColumn.sourceTable,
|
||||
});
|
||||
|
||||
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
|
||||
const isBasicEntityJoin =
|
||||
correctedJoinAlias === `${sourceColumn}_name`;
|
||||
additionalColumn.joinAlias ===
|
||||
`${baseJoinConfig.sourceColumn}_name`;
|
||||
|
||||
if (isBasicEntityJoin) {
|
||||
logger.info(
|
||||
`⚠️ 기본 Entity 조인과 중복: ${correctedJoinAlias} - 건너뜀`
|
||||
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
|
||||
);
|
||||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||
}
|
||||
|
|
@ -2859,14 +2760,14 @@ export class TableManagementService {
|
|||
// 추가 조인 컬럼 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
|
|
@ -3226,10 +3127,8 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
// Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함)
|
||||
// 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식
|
||||
const allEntityColumns = [
|
||||
...joinConfigs.map((config) => config.aliasColumn),
|
||||
...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함
|
||||
// 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등)
|
||||
...joinConfigs.flatMap((config) => {
|
||||
const additionalColumns = [];
|
||||
|
|
@ -3635,10 +3534,8 @@ export class TableManagementService {
|
|||
});
|
||||
|
||||
// main. 접두사 추가 (조인 쿼리용)
|
||||
// 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등)
|
||||
// Negative lookbehind (?<!\.) 사용: 앞에 .이 없는 경우만 매칭
|
||||
condition = condition.replace(
|
||||
new RegExp(`(?<!\\.)\\b${columnName}\\b`, "g"),
|
||||
new RegExp(`\\b${columnName}\\b`, "g"),
|
||||
`main.${columnName}`
|
||||
);
|
||||
conditions.push(condition);
|
||||
|
|
@ -3837,18 +3734,6 @@ export class TableManagementService {
|
|||
const cacheableJoins: EntityJoinConfig[] = [];
|
||||
const dbJoins: EntityJoinConfig[] = [];
|
||||
|
||||
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||
const companySpecificTables = [
|
||||
"supplier_mng",
|
||||
"customer_mng",
|
||||
"item_info",
|
||||
"dept_info",
|
||||
"sales_order_mng", // 🔧 수주관리 테이블 추가
|
||||
"sales_order_detail", // 🔧 수주상세 테이블 추가
|
||||
"partner_info", // 🔧 거래처 테이블 추가
|
||||
// 필요시 추가
|
||||
];
|
||||
|
||||
for (const config of joinConfigs) {
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
|
|
@ -3857,13 +3742,6 @@ export class TableManagementService {
|
|||
continue;
|
||||
}
|
||||
|
||||
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||
if (companySpecificTables.includes(config.referenceTable)) {
|
||||
dbJoins.push(config);
|
||||
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 캐시 가능성 확인
|
||||
const cachedData = await referenceCacheService.getCachedReference(
|
||||
config.referenceTable,
|
||||
|
|
@ -4752,110 +4630,4 @@ export class TableManagementService {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
||||
*
|
||||
* @param leftTable 좌측 테이블명
|
||||
* @param rightTable 우측 테이블명
|
||||
* @returns 감지된 엔티티 관계 배열
|
||||
*/
|
||||
async detectTableEntityRelations(
|
||||
leftTable: string,
|
||||
rightTable: string
|
||||
): Promise<
|
||||
Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
direction: "left_to_right" | "right_to_left";
|
||||
inputType: string;
|
||||
displayColumn?: string;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
logger.info(
|
||||
`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`
|
||||
);
|
||||
|
||||
const relations: Array<{
|
||||
leftColumn: string;
|
||||
rightColumn: string;
|
||||
direction: "left_to_right" | "right_to_left";
|
||||
inputType: string;
|
||||
displayColumn?: string;
|
||||
}> = [];
|
||||
|
||||
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
|
||||
const rightToLeftRels = await query<{
|
||||
column_name: string;
|
||||
reference_column: string;
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''`,
|
||||
[rightTable, leftTable]
|
||||
);
|
||||
|
||||
for (const rel of rightToLeftRels) {
|
||||
relations.push({
|
||||
leftColumn: rel.reference_column,
|
||||
rightColumn: rel.column_name,
|
||||
direction: "right_to_left",
|
||||
inputType: rel.input_type,
|
||||
displayColumn: rel.display_column || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||
// 예: left_table의 item_id -> right_table(item_info)의 item_number
|
||||
const leftToRightRels = await query<{
|
||||
column_name: string;
|
||||
reference_column: string;
|
||||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''`,
|
||||
[leftTable, rightTable]
|
||||
);
|
||||
|
||||
for (const rel of leftToRightRels) {
|
||||
relations.push({
|
||||
leftColumn: rel.column_name,
|
||||
rightColumn: rel.reference_column,
|
||||
direction: "left_to_right",
|
||||
inputType: rel.input_type,
|
||||
displayColumn: rel.display_column || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
||||
relations.forEach((rel, idx) => {
|
||||
logger.info(
|
||||
` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`
|
||||
);
|
||||
});
|
||||
|
||||
return relations;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,30 +17,12 @@ export interface LangKey {
|
|||
langKey: string;
|
||||
description?: string;
|
||||
isActive: string;
|
||||
categoryId?: number;
|
||||
keyMeaning?: string;
|
||||
usageNote?: string;
|
||||
baseKeyId?: number;
|
||||
createdDate?: Date;
|
||||
createdBy?: string;
|
||||
updatedDate?: Date;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
// 카테고리 인터페이스
|
||||
export interface LangCategory {
|
||||
categoryId: number;
|
||||
categoryCode: string;
|
||||
categoryName: string;
|
||||
parentId?: number | null;
|
||||
level: number;
|
||||
keyPrefix: string;
|
||||
description?: string;
|
||||
sortOrder: number;
|
||||
isActive: string;
|
||||
children?: LangCategory[];
|
||||
}
|
||||
|
||||
export interface LangText {
|
||||
textId?: number;
|
||||
keyId: number;
|
||||
|
|
@ -81,38 +63,10 @@ export interface CreateLangKeyRequest {
|
|||
langKey: string;
|
||||
description?: string;
|
||||
isActive?: string;
|
||||
categoryId?: number;
|
||||
keyMeaning?: string;
|
||||
usageNote?: string;
|
||||
baseKeyId?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
// 자동 키 생성 요청
|
||||
export interface GenerateKeyRequest {
|
||||
companyCode: string;
|
||||
categoryId: number;
|
||||
keyMeaning: string;
|
||||
usageNote?: string;
|
||||
texts: Array<{
|
||||
langCode: string;
|
||||
langText: string;
|
||||
}>;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
// 오버라이드 키 생성 요청
|
||||
export interface CreateOverrideKeyRequest {
|
||||
companyCode: string;
|
||||
baseKeyId: number;
|
||||
texts: Array<{
|
||||
langCode: string;
|
||||
langText: string;
|
||||
}>;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLangKeyRequest {
|
||||
companyCode?: string;
|
||||
menuName?: string;
|
||||
|
|
@ -136,8 +90,6 @@ export interface GetLangKeysParams {
|
|||
menuCode?: string;
|
||||
keyType?: string;
|
||||
searchText?: string;
|
||||
categoryId?: number;
|
||||
includeOverrides?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -588,4 +588,3 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,597 +0,0 @@
|
|||
# 다국어 관리 시스템 개선 계획서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 현재 시스템 분석
|
||||
|
||||
현재 ERP 시스템의 다국어 관리 시스템은 기본적인 기능은 갖추고 있으나 다음과 같은 한계점이 있습니다.
|
||||
|
||||
| 항목 | 현재 상태 | 문제점 |
|
||||
|------|----------|--------|
|
||||
| 회사별 다국어 | `company_code` 컬럼 존재하나 `*`(공통)만 사용 | 회사별 커스텀 번역 불가 |
|
||||
| 언어 키 입력 | 수동 입력 (`button.add` 등) | 명명 규칙 불일치, 오타, 중복 위험 |
|
||||
| 카테고리 분류 | 없음 (`menu_name` 텍스트만 존재) | 체계적 분류/검색 불가 |
|
||||
| 권한 관리 | 없음 | 모든 사용자가 모든 키 수정 가능 |
|
||||
| 조회 우선순위 | 없음 | 회사별 오버라이드 불가 |
|
||||
|
||||
### 1.2 개선 목표
|
||||
|
||||
1. **회사별 다국어 오버라이드 시스템**: 공통 키를 기본으로 사용하되, 회사별 커스텀 번역 지원
|
||||
2. **권한 기반 접근 제어**: 공통 키는 최고 관리자만, 회사 키는 해당 회사만 수정
|
||||
3. **카테고리 기반 분류**: 2단계 계층 구조로 체계적 분류
|
||||
4. **자동 키 생성**: 카테고리 선택 + 의미 입력으로 규칙화된 키 자동 생성
|
||||
5. **실시간 중복 체크**: 키 생성 시 중복 여부 즉시 확인
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터베이스 스키마 설계
|
||||
|
||||
### 2.1 신규 테이블: multi_lang_category (카테고리 마스터)
|
||||
|
||||
```sql
|
||||
CREATE TABLE multi_lang_category (
|
||||
category_id SERIAL PRIMARY KEY,
|
||||
category_code VARCHAR(50) NOT NULL, -- BUTTON, FORM, MESSAGE 등
|
||||
category_name VARCHAR(100) NOT NULL, -- 버튼, 폼, 메시지 등
|
||||
parent_id INT4 REFERENCES multi_lang_category(category_id),
|
||||
level INT4 DEFAULT 1, -- 1=대분류, 2=세부분류
|
||||
key_prefix VARCHAR(50) NOT NULL, -- 키 생성용 prefix
|
||||
description TEXT,
|
||||
sort_order INT4 DEFAULT 0,
|
||||
is_active CHAR(1) DEFAULT 'Y',
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
UNIQUE(category_code, COALESCE(parent_id, 0))
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_lang_category_parent ON multi_lang_category(parent_id);
|
||||
CREATE INDEX idx_lang_category_level ON multi_lang_category(level);
|
||||
```
|
||||
|
||||
### 2.2 기존 테이블 수정: multi_lang_key_master
|
||||
|
||||
```sql
|
||||
-- 카테고리 연결 컬럼 추가
|
||||
ALTER TABLE multi_lang_key_master
|
||||
ADD COLUMN category_id INT4 REFERENCES multi_lang_category(category_id);
|
||||
|
||||
-- 키 의미 컬럼 추가 (자동 생성 시 사용자 입력값)
|
||||
ALTER TABLE multi_lang_key_master
|
||||
ADD COLUMN key_meaning VARCHAR(100);
|
||||
|
||||
-- 원본 키 참조 (오버라이드 시 원본 추적)
|
||||
ALTER TABLE multi_lang_key_master
|
||||
ADD COLUMN base_key_id INT4 REFERENCES multi_lang_key_master(key_id);
|
||||
|
||||
-- menu_name을 usage_note로 변경 (사용 위치 메모)
|
||||
ALTER TABLE multi_lang_key_master
|
||||
RENAME COLUMN menu_name TO usage_note;
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX idx_lang_key_category ON multi_lang_key_master(category_id);
|
||||
CREATE INDEX idx_lang_key_company_category ON multi_lang_key_master(company_code, category_id);
|
||||
CREATE INDEX idx_lang_key_base ON multi_lang_key_master(base_key_id);
|
||||
```
|
||||
|
||||
### 2.3 테이블 관계도
|
||||
|
||||
```
|
||||
multi_lang_category (1) ◀────────┐
|
||||
├── category_id (PK) │
|
||||
├── category_code │
|
||||
├── parent_id (자기참조) │
|
||||
└── key_prefix │
|
||||
│
|
||||
multi_lang_key_master (N) ────────┘
|
||||
├── key_id (PK)
|
||||
├── company_code ('*' = 공통)
|
||||
├── category_id (FK)
|
||||
├── lang_key (자동 생성)
|
||||
├── key_meaning (사용자 입력)
|
||||
├── base_key_id (오버라이드 시 원본)
|
||||
└── usage_note (사용 위치 메모)
|
||||
│
|
||||
▼
|
||||
multi_lang_text (N)
|
||||
├── text_id (PK)
|
||||
├── key_id (FK)
|
||||
├── lang_code (FK → language_master)
|
||||
└── lang_text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 카테고리 체계
|
||||
|
||||
### 3.1 대분류 (Level 1)
|
||||
|
||||
| category_code | category_name | key_prefix | 설명 |
|
||||
|---------------|---------------|------------|------|
|
||||
| COMMON | 공통 | common | 범용 텍스트 |
|
||||
| BUTTON | 버튼 | button | 버튼 텍스트 |
|
||||
| FORM | 폼 | form | 폼 라벨, 플레이스홀더 |
|
||||
| TABLE | 테이블 | table | 테이블 헤더, 빈 상태 |
|
||||
| MESSAGE | 메시지 | message | 알림, 경고, 성공 메시지 |
|
||||
| MENU | 메뉴 | menu | 메뉴명, 네비게이션 |
|
||||
| MODAL | 모달 | modal | 모달/다이얼로그 |
|
||||
| VALIDATION | 검증 | validation | 유효성 검사 메시지 |
|
||||
| STATUS | 상태 | status | 상태 표시 텍스트 |
|
||||
| TOOLTIP | 툴팁 | tooltip | 툴팁, 도움말 |
|
||||
|
||||
### 3.2 세부분류 (Level 2)
|
||||
|
||||
#### BUTTON 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| ACTION | 액션 | action |
|
||||
| NAVIGATION | 네비게이션 | nav |
|
||||
| TOGGLE | 토글 | toggle |
|
||||
|
||||
#### FORM 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| LABEL | 라벨 | label |
|
||||
| PLACEHOLDER | 플레이스홀더 | placeholder |
|
||||
| HELPER | 도움말 | helper |
|
||||
|
||||
#### MESSAGE 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| SUCCESS | 성공 | success |
|
||||
| ERROR | 에러 | error |
|
||||
| WARNING | 경고 | warning |
|
||||
| INFO | 안내 | info |
|
||||
| CONFIRM | 확인 | confirm |
|
||||
|
||||
#### TABLE 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| HEADER | 헤더 | header |
|
||||
| EMPTY | 빈 상태 | empty |
|
||||
| PAGINATION | 페이지네이션 | pagination |
|
||||
|
||||
#### MENU 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| ADMIN | 관리자 | admin |
|
||||
| USER | 사용자 | user |
|
||||
|
||||
#### MODAL 하위
|
||||
| category_code | category_name | key_prefix |
|
||||
|---------------|---------------|------------|
|
||||
| TITLE | 제목 | title |
|
||||
| DESCRIPTION | 설명 | description |
|
||||
|
||||
### 3.3 키 자동 생성 규칙
|
||||
|
||||
**형식**: `{대분류_prefix}.{세부분류_prefix}.{key_meaning}`
|
||||
|
||||
**예시**:
|
||||
| 대분류 | 세부분류 | 의미 입력 | 생성 키 |
|
||||
|--------|----------|----------|---------|
|
||||
| BUTTON | ACTION | save | `button.action.save` |
|
||||
| BUTTON | ACTION | delete_selected | `button.action.delete_selected` |
|
||||
| FORM | LABEL | user_name | `form.label.user_name` |
|
||||
| FORM | PLACEHOLDER | search | `form.placeholder.search` |
|
||||
| MESSAGE | SUCCESS | save_complete | `message.success.save_complete` |
|
||||
| MESSAGE | ERROR | network_fail | `message.error.network_fail` |
|
||||
| TABLE | HEADER | created_date | `table.header.created_date` |
|
||||
| MENU | ADMIN | user_management | `menu.admin.user_management` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 회사별 다국어 시스템
|
||||
|
||||
### 4.1 조회 우선순위
|
||||
|
||||
다국어 텍스트 조회 시 다음 우선순위를 적용합니다:
|
||||
|
||||
1. **회사 전용 키** (`company_code = 'COMPANY_A'`)
|
||||
2. **공통 키** (`company_code = '*'`)
|
||||
|
||||
```sql
|
||||
-- 조회 쿼리 예시
|
||||
WITH ranked_keys AS (
|
||||
SELECT
|
||||
km.lang_key,
|
||||
mt.lang_text,
|
||||
km.company_code,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY km.lang_key
|
||||
ORDER BY CASE WHEN km.company_code = $1 THEN 1 ELSE 2 END
|
||||
) as priority
|
||||
FROM multi_lang_key_master km
|
||||
JOIN multi_lang_text mt ON km.key_id = mt.key_id
|
||||
WHERE km.lang_key = ANY($2)
|
||||
AND mt.lang_code = $3
|
||||
AND km.is_active = 'Y'
|
||||
AND km.company_code IN ($1, '*')
|
||||
)
|
||||
SELECT lang_key, lang_text
|
||||
FROM ranked_keys
|
||||
WHERE priority = 1;
|
||||
```
|
||||
|
||||
### 4.2 오버라이드 프로세스
|
||||
|
||||
1. 회사 관리자가 공통 키에서 "이 회사 전용으로 복사" 클릭
|
||||
2. 시스템이 `base_key_id`에 원본 키를 참조하는 새 키 생성
|
||||
3. 기존 번역 텍스트 복사
|
||||
4. 회사 관리자가 번역 수정
|
||||
5. 이후 해당 회사 사용자는 회사 전용 번역 사용
|
||||
|
||||
### 4.3 권한 매트릭스
|
||||
|
||||
| 작업 | 최고 관리자 (`*`) | 회사 관리자 | 일반 사용자 |
|
||||
|------|------------------|-------------|-------------|
|
||||
| 공통 키 조회 | O | O | O |
|
||||
| 공통 키 생성 | O | X | X |
|
||||
| 공통 키 수정 | O | X | X |
|
||||
| 공통 키 삭제 | O | X | X |
|
||||
| 회사 키 조회 | O | 자사만 | 자사만 |
|
||||
| 회사 키 생성 (오버라이드) | O | O | X |
|
||||
| 회사 키 수정 | O | 자사만 | X |
|
||||
| 회사 키 삭제 | O | 자사만 | X |
|
||||
| 카테고리 관리 | O | X | X |
|
||||
|
||||
---
|
||||
|
||||
## 5. API 설계
|
||||
|
||||
### 5.1 카테고리 API
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 | 권한 |
|
||||
|-----------|--------|------|------|
|
||||
| `/multilang/categories` | GET | 카테고리 목록 조회 | 인증 필요 |
|
||||
| `/multilang/categories/tree` | GET | 계층 구조로 조회 | 인증 필요 |
|
||||
| `/multilang/categories` | POST | 카테고리 생성 | 최고 관리자 |
|
||||
| `/multilang/categories/:id` | PUT | 카테고리 수정 | 최고 관리자 |
|
||||
| `/multilang/categories/:id` | DELETE | 카테고리 삭제 | 최고 관리자 |
|
||||
|
||||
### 5.2 다국어 키 API (개선)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 | 권한 |
|
||||
|-----------|--------|------|------|
|
||||
| `/multilang/keys` | GET | 키 목록 조회 (카테고리/회사 필터) | 인증 필요 |
|
||||
| `/multilang/keys` | POST | 키 생성 | 공통: 최고관리자, 회사: 회사관리자 |
|
||||
| `/multilang/keys/:keyId` | PUT | 키 수정 | 공통: 최고관리자, 회사: 해당회사 |
|
||||
| `/multilang/keys/:keyId` | DELETE | 키 삭제 | 공통: 최고관리자, 회사: 해당회사 |
|
||||
| `/multilang/keys/:keyId/override` | POST | 공통 키를 회사 전용으로 복사 | 회사 관리자 |
|
||||
| `/multilang/keys/check` | GET | 키 중복 체크 | 인증 필요 |
|
||||
| `/multilang/keys/generate-preview` | POST | 키 자동 생성 미리보기 | 인증 필요 |
|
||||
|
||||
### 5.3 API 요청/응답 예시
|
||||
|
||||
#### 키 생성 요청
|
||||
```json
|
||||
POST /multilang/keys
|
||||
{
|
||||
"categoryId": 11, // 세부분류 ID (BUTTON > ACTION)
|
||||
"keyMeaning": "save_changes",
|
||||
"description": "변경사항 저장 버튼",
|
||||
"usageNote": "사용자 관리, 설정 화면",
|
||||
"texts": [
|
||||
{ "langCode": "KR", "langText": "저장하기" },
|
||||
{ "langCode": "US", "langText": "Save Changes" },
|
||||
{ "langCode": "JP", "langText": "保存する" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 키 생성 응답
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "다국어 키가 생성되었습니다.",
|
||||
"data": {
|
||||
"keyId": 175,
|
||||
"langKey": "button.action.save_changes",
|
||||
"companyCode": "*",
|
||||
"categoryId": 11
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 오버라이드 요청
|
||||
```json
|
||||
POST /multilang/keys/123/override
|
||||
{
|
||||
"texts": [
|
||||
{ "langCode": "KR", "langText": "등록하기" },
|
||||
{ "langCode": "US", "langText": "Register" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 UI 설계
|
||||
|
||||
### 6.1 다국어 관리 페이지 리뉴얼
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 다국어 관리 │
|
||||
│ 다국어 키와 번역 텍스트를 관리합니다 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ [언어 관리] [다국어 키 관리] [카테고리 관리] │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────┐ ┌───────────────────────────────────────────────┤
|
||||
│ │ 카테고리 필터 │ │ │
|
||||
│ │ │ │ 검색: [________________] 회사: [전체 ▼] │
|
||||
│ │ ▼ 버튼 (45) │ │ [초기화] [+ 키 등록] │
|
||||
│ │ ├ 액션 (30) │ │───────────────────────────────────────────────│
|
||||
│ │ ├ 네비게이션 (10)│ │ ☐ │ 키 │ 카테고리 │ 회사 │ 상태 │
|
||||
│ │ └ 토글 (5) │ │───────────────────────────────────────────────│
|
||||
│ │ ▼ 폼 (60) │ │ ☐ │ button.action.save │ 버튼>액션 │ 공통 │ 활성 │
|
||||
│ │ ├ 라벨 (35) │ │ ☐ │ button.action.save │ 버튼>액션 │ A사 │ 활성 │
|
||||
│ │ ├ 플레이스홀더(15)│ │ ☐ │ button.action.delete │ 버튼>액션 │ 공통 │ 활성 │
|
||||
│ │ └ 도움말 (10) │ │ ☐ │ form.label.user_name │ 폼>라벨 │ 공통 │ 활성 │
|
||||
│ │ ▶ 메시지 (40) │ │───────────────────────────────────────────────│
|
||||
│ │ ▶ 테이블 (20) │ │ 페이지: [1] [2] [3] ... [10] │
|
||||
│ │ ▶ 메뉴 (9) │ │ │
|
||||
│ └────────────────────┘ └───────────────────────────────────────────────┤
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 키 등록 모달
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 다국어 키 등록 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ① 카테고리 선택 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┤
|
||||
│ │ 대분류 * │ 세부 분류 * │
|
||||
│ │ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
|
||||
│ │ │ 공통 │ │ │ (대분류 먼저 선택) │ │
|
||||
│ │ │ ● 버튼 │ │ │ ● 액션 │ │
|
||||
│ │ │ 폼 │ │ │ 네비게이션 │ │
|
||||
│ │ │ 테이블 │ │ │ 토글 │ │
|
||||
│ │ │ 메시지 │ │ │ │ │
|
||||
│ │ └─────────────────────────┘ │ └─────────────────────────┘ │
|
||||
│ └───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ② 키 정보 입력 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┤
|
||||
│ │ 키 의미 (영문) * │
|
||||
│ │ [ save_changes ] │
|
||||
│ │ 영문 소문자, 밑줄(_) 사용. 예: save, add_new, delete_all │
|
||||
│ │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │
|
||||
│ │ 자동 생성 키: │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ button.action.save_changes │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │ ✓ 사용 가능한 키입니다 │
|
||||
│ └───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ③ 설명 및 번역 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┤
|
||||
│ │ 설명 (선택) │
|
||||
│ │ [ 변경사항을 저장하는 버튼 ] │
|
||||
│ │ │
|
||||
│ │ 사용 위치 메모 (선택) │
|
||||
│ │ [ 사용자 관리, 설정 화면 ] │
|
||||
│ │ │
|
||||
│ │ ───────────────────────────────────────────────────────── │
|
||||
│ │ 번역 텍스트 │
|
||||
│ │ │
|
||||
│ │ 한국어 (KR) * [ 저장하기 ] │
|
||||
│ │ English (US) [ Save Changes ] │
|
||||
│ │ 日本語 (JP) [ 保存する ] │
|
||||
│ └───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [취소] [등록] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 공통 키 편집 모달 (회사 관리자용)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 다국어 키 상세 │
|
||||
│ button.action.save (공통) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 카테고리: 버튼 > 액션 │
|
||||
│ 설명: 저장 버튼 │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ 번역 텍스트 (읽기 전용) │
|
||||
│ │
|
||||
│ 한국어 (KR) 저장 │
|
||||
│ English (US) Save │
|
||||
│ 日本語 (JP) 保存 │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 공통 키는 수정할 수 없습니다. │
|
||||
│ 이 회사만의 번역이 필요하시면 아래 버튼을 클릭하세요. │
|
||||
│ │
|
||||
│ [이 회사 전용으로 복사] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [닫기] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 회사 전용 키 생성 모달 (오버라이드)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 회사 전용 키 생성 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 원본 키: button.action.save (공통) │
|
||||
│ │
|
||||
│ 원본 번역: │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 한국어: 저장 │ │
|
||||
│ │ English: Save │ │
|
||||
│ │ 日本語: 保存 │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 이 회사 전용 번역 텍스트: │
|
||||
│ │
|
||||
│ 한국어 (KR) * [ 등록하기 ] │
|
||||
│ English (US) [ Register ] │
|
||||
│ 日本語 (JP) [ 登録 ] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 회사 전용 키를 생성하면 공통 키 대신 사용됩니다. │
|
||||
│ 원본 키가 변경되어도 회사 전용 키는 영향받지 않습니다. │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [취소] [생성] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 계획
|
||||
|
||||
### 7.1 Phase 1: 데이터베이스 마이그레이션
|
||||
|
||||
**예상 소요 시간: 2시간**
|
||||
|
||||
1. 카테고리 테이블 생성
|
||||
2. 기본 카테고리 데이터 삽입 (대분류 10개, 세부분류 약 20개)
|
||||
3. multi_lang_key_master 스키마 변경
|
||||
4. 기존 174개 키 카테고리 자동 분류 (패턴 매칭)
|
||||
|
||||
**마이그레이션 파일**: `db/migrations/075_multilang_category_system.sql`
|
||||
|
||||
### 7.2 Phase 2: 백엔드 API 개발
|
||||
|
||||
**예상 소요 시간: 4시간**
|
||||
|
||||
1. 카테고리 CRUD API
|
||||
2. 키 조회 로직 수정 (우선순위 적용)
|
||||
3. 권한 검사 미들웨어
|
||||
4. 오버라이드 API
|
||||
5. 키 중복 체크 API
|
||||
6. 키 자동 생성 미리보기 API
|
||||
|
||||
**관련 파일**:
|
||||
- `backend-node/src/controllers/multilangController.ts`
|
||||
- `backend-node/src/services/multilangService.ts`
|
||||
- `backend-node/src/routes/multilangRoutes.ts`
|
||||
|
||||
### 7.3 Phase 3: 프론트엔드 UI 개발
|
||||
|
||||
**예상 소요 시간: 6시간**
|
||||
|
||||
1. 카테고리 트리 컴포넌트
|
||||
2. 키 등록 모달 리뉴얼 (단계별 입력)
|
||||
3. 키 편집 모달 (권한별 UI 분기)
|
||||
4. 오버라이드 모달
|
||||
5. 카테고리 관리 탭 추가
|
||||
|
||||
**관련 파일**:
|
||||
- `frontend/app/(main)/admin/systemMng/i18nList/page.tsx`
|
||||
- `frontend/components/multilang/LangKeyModal.tsx` (리뉴얼)
|
||||
- `frontend/components/multilang/CategoryTree.tsx` (신규)
|
||||
- `frontend/components/multilang/OverrideModal.tsx` (신규)
|
||||
|
||||
### 7.4 Phase 4: 테스트 및 마이그레이션
|
||||
|
||||
**예상 소요 시간: 2시간**
|
||||
|
||||
1. API 테스트
|
||||
2. UI 테스트
|
||||
3. 기존 데이터 마이그레이션 검증
|
||||
4. 권한 테스트 (최고 관리자, 회사 관리자)
|
||||
|
||||
---
|
||||
|
||||
## 8. 상세 구현 일정
|
||||
|
||||
| 단계 | 작업 | 예상 시간 | 의존성 |
|
||||
|------|------|----------|--------|
|
||||
| 1.1 | 마이그레이션 SQL 작성 | 30분 | - |
|
||||
| 1.2 | 카테고리 기본 데이터 삽입 | 30분 | 1.1 |
|
||||
| 1.3 | 기존 키 카테고리 자동 분류 | 30분 | 1.2 |
|
||||
| 1.4 | 스키마 변경 검증 | 30분 | 1.3 |
|
||||
| 2.1 | 카테고리 API 개발 | 1시간 | 1.4 |
|
||||
| 2.2 | 키 조회 로직 수정 (우선순위) | 1시간 | 2.1 |
|
||||
| 2.3 | 권한 검사 로직 추가 | 30분 | 2.2 |
|
||||
| 2.4 | 오버라이드 API 개발 | 1시간 | 2.3 |
|
||||
| 2.5 | 키 생성 API 개선 (자동 생성) | 30분 | 2.4 |
|
||||
| 3.1 | 카테고리 트리 컴포넌트 | 1시간 | 2.5 |
|
||||
| 3.2 | 키 등록 모달 리뉴얼 | 2시간 | 3.1 |
|
||||
| 3.3 | 키 편집/상세 모달 | 1시간 | 3.2 |
|
||||
| 3.4 | 오버라이드 모달 | 1시간 | 3.3 |
|
||||
| 3.5 | 카테고리 관리 탭 | 1시간 | 3.4 |
|
||||
| 4.1 | 통합 테스트 | 1시간 | 3.5 |
|
||||
| 4.2 | 버그 수정 및 마무리 | 1시간 | 4.1 |
|
||||
|
||||
**총 예상 시간: 약 14시간**
|
||||
|
||||
---
|
||||
|
||||
## 9. 기대 효과
|
||||
|
||||
### 9.1 개선 전후 비교
|
||||
|
||||
| 항목 | 현재 | 개선 후 |
|
||||
|------|------|---------|
|
||||
| 키 명명 규칙 | 불규칙 (수동 입력) | 규칙화 (자동 생성) |
|
||||
| 카테고리 분류 | 없음 | 2단계 계층 구조 |
|
||||
| 회사별 다국어 | 미활용 | 오버라이드 지원 |
|
||||
| 조회 우선순위 | 없음 | 회사 전용 > 공통 |
|
||||
| 권한 관리 | 없음 | 역할별 접근 제어 |
|
||||
| 중복 체크 | 저장 시에만 | 실시간 검증 |
|
||||
| 검색/필터 | 키 이름만 | 카테고리 + 회사 + 키 |
|
||||
|
||||
### 9.2 사용자 경험 개선
|
||||
|
||||
1. **일관된 키 명명**: 자동 생성으로 규칙 준수
|
||||
2. **빠른 검색**: 카테고리 기반 필터링
|
||||
3. **회사별 커스터마이징**: 브랜드에 맞는 번역 사용
|
||||
4. **안전한 수정**: 권한 기반 보호
|
||||
|
||||
### 9.3 유지보수 개선
|
||||
|
||||
1. **체계적 분류**: 어떤 텍스트가 어디에 사용되는지 명확
|
||||
2. **변경 영향 파악**: 오버라이드 추적으로 영향 범위 확인
|
||||
3. **권한 분리**: 공통 키 보호, 회사별 자율성 보장
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
### 10.1 관련 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `frontend/hooks/useMultiLang.ts` | 다국어 훅 |
|
||||
| `frontend/lib/utils/multilang.ts` | 다국어 유틸리티 |
|
||||
| `frontend/app/(main)/admin/systemMng/i18nList/page.tsx` | 다국어 관리 페이지 |
|
||||
| `backend-node/src/controllers/multilangController.ts` | API 컨트롤러 |
|
||||
| `backend-node/src/services/multilangService.ts` | 비즈니스 로직 |
|
||||
| `docs/다국어_시스템_가이드.md` | 기존 시스템 가이드 |
|
||||
|
||||
### 10.2 데이터베이스 테이블
|
||||
|
||||
| 테이블 | 설명 |
|
||||
|--------|------|
|
||||
| `language_master` | 언어 마스터 (KR, US, JP) |
|
||||
| `multi_lang_key_master` | 다국어 키 마스터 |
|
||||
| `multi_lang_text` | 다국어 번역 텍스트 |
|
||||
| `multi_lang_category` | 다국어 카테고리 (신규) |
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 작성자 | 변경 내용 |
|
||||
|------|------|--------|----------|
|
||||
| 1.0 | 2026-01-13 | AI | 최초 작성 |
|
||||
|
||||
|
||||
|
|
@ -361,4 +361,3 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -347,4 +347,3 @@ const getComponentValue = (componentId: string) => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,127 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import ScreenList from "@/components/screen/ScreenList";
|
||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||
import TemplateManager from "@/components/screen/TemplateManager";
|
||||
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
|
||||
import { ScreenRelationFlow } from "@/components/screen/ScreenRelationFlow";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||
|
||||
// 단계별 진행을 위한 타입 정의
|
||||
type Step = "list" | "design" | "template";
|
||||
type ViewMode = "tree" | "table";
|
||||
|
||||
export default function ScreenManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
|
||||
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("tree");
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
const loadScreens = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" });
|
||||
// screenApi.getScreens는 { data: ScreenDefinition[], total, page, size, totalPages } 형태 반환
|
||||
if (result.data && result.data.length > 0) {
|
||||
setScreens(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadScreens();
|
||||
}, [loadScreens]);
|
||||
|
||||
// 화면 목록 새로고침 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleScreenListRefresh = () => {
|
||||
console.log("🔄 화면 목록 새로고침 이벤트 수신");
|
||||
loadScreens();
|
||||
};
|
||||
|
||||
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||
return () => {
|
||||
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||
};
|
||||
}, [loadScreens]);
|
||||
|
||||
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
||||
useEffect(() => {
|
||||
const openDesignerId = searchParams.get("openDesigner");
|
||||
if (openDesignerId && screens.length > 0) {
|
||||
const screenId = parseInt(openDesignerId, 10);
|
||||
const targetScreen = screens.find((s) => s.screenId === screenId);
|
||||
if (targetScreen) {
|
||||
setSelectedScreen(targetScreen);
|
||||
setCurrentStep("design");
|
||||
setStepHistory(["list", "design"]);
|
||||
}
|
||||
}
|
||||
}, [searchParams, screens]);
|
||||
|
||||
// 화면 설계 모드일 때는 전체 화면 사용
|
||||
const isDesignMode = currentStep === "design";
|
||||
|
||||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
title: "화면 목록 관리",
|
||||
description: "생성된 화면들을 확인하고 관리하세요",
|
||||
},
|
||||
design: {
|
||||
title: "화면 설계",
|
||||
description: "드래그앤드롭으로 화면을 설계하세요",
|
||||
},
|
||||
template: {
|
||||
title: "템플릿 관리",
|
||||
description: "화면 템플릿을 관리하고 재사용하세요",
|
||||
},
|
||||
};
|
||||
|
||||
// 다음 단계로 이동
|
||||
const goToNextStep = (nextStep: Step) => {
|
||||
setStepHistory((prev) => [...prev, nextStep]);
|
||||
setCurrentStep(nextStep);
|
||||
};
|
||||
|
||||
// 이전 단계로 이동
|
||||
const goToPreviousStep = () => {
|
||||
if (stepHistory.length > 1) {
|
||||
const newHistory = stepHistory.slice(0, -1);
|
||||
const previousStep = newHistory[newHistory.length - 1];
|
||||
setStepHistory(newHistory);
|
||||
setCurrentStep(previousStep);
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 단계로 이동
|
||||
const goToStep = (step: Step) => {
|
||||
setCurrentStep(step);
|
||||
// 해당 단계까지의 히스토리만 유지
|
||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||
if (stepIndex !== -1) {
|
||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
setSelectedScreen(screen);
|
||||
setSelectedGroup(null); // 그룹 선택 해제
|
||||
};
|
||||
|
||||
// 화면 디자인 핸들러
|
||||
const handleDesignScreen = (screen: ScreenDefinition) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
};
|
||||
|
||||
// 검색어로 필터링된 화면
|
||||
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
|
||||
// 단일 키워드면 해당 키워드로 화면 필터링
|
||||
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||
const filteredScreens = searchKeywords.length > 1
|
||||
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
|
||||
: screens.filter((screen) =>
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
|
||||
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이)
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
|
|
@ -131,119 +72,59 @@ export default function ScreenManagementPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">화면 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">화면을 그룹별로 관리하고 데이터 관계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 뷰 모드 전환 */}
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="tree" className="gap-1.5 px-3">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
트리
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table" className="gap-1.5 px-3">
|
||||
<LayoutList className="h-4 w-4" />
|
||||
테이블
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 화면
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">화면 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">화면을 설계하고 템플릿을 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1">
|
||||
{/* 화면 목록 단계 */}
|
||||
{currentStep === "list" && (
|
||||
<ScreenList
|
||||
onScreenSelect={setSelectedScreen}
|
||||
selectedScreen={selectedScreen}
|
||||
onDesignScreen={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 템플릿 관리 단계 */}
|
||||
{currentStep === "template" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||
<h2 className="text-xl font-semibold">{stepConfig.template.title}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToPreviousStep}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전 단계
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => goToStep("list")}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
목록으로 돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
{viewMode === "tree" ? (
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* 왼쪽: 트리 구조 */}
|
||||
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
|
||||
{/* 검색 */}
|
||||
<div className="flex-shrink-0 p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 트리 뷰 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScreenGroupTreeView
|
||||
screens={filteredScreens}
|
||||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
searchTerm={searchTerm}
|
||||
onGroupSelect={(group) => {
|
||||
setSelectedGroup(group);
|
||||
setSelectedScreen(null); // 화면 선택 해제
|
||||
setFocusedScreenIdInGroup(null); // 포커스 초기화
|
||||
}}
|
||||
onScreenSelectInGroup={(group, screenId) => {
|
||||
// 그룹 내 화면 클릭 시
|
||||
const isNewGroup = selectedGroup?.id !== group.id;
|
||||
|
||||
if (isNewGroup) {
|
||||
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
|
||||
setSelectedGroup(group);
|
||||
setFocusedScreenIdInGroup(null);
|
||||
} else {
|
||||
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
|
||||
setFocusedScreenIdInGroup(screenId);
|
||||
}
|
||||
setSelectedScreen(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 관계 시각화 (React Flow) */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScreenRelationFlow
|
||||
screen={selectedScreen}
|
||||
selectedGroup={selectedGroup}
|
||||
initialFocusedScreenId={focusedScreenIdInGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 테이블 뷰 (기존 ScreenList 사용)
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<ScreenList
|
||||
onScreenSelect={handleScreenSelect}
|
||||
selectedScreen={selectedScreen}
|
||||
onDesignScreen={handleDesignScreen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 생성 모달 */}
|
||||
<CreateScreenModal
|
||||
isOpen={isCreateOpen}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
onSuccess={() => {
|
||||
setIsCreateOpen(false);
|
||||
loadScreens();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,19 +7,13 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { DataTable } from "@/components/common/DataTable";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import LangKeyModal from "@/components/admin/LangKeyModal";
|
||||
import LanguageModal from "@/components/admin/LanguageModal";
|
||||
import { CategoryTree } from "@/components/admin/multilang/CategoryTree";
|
||||
import { KeyGenerateModal } from "@/components/admin/multilang/KeyGenerateModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { LangCategory } from "@/lib/api/multilang";
|
||||
|
||||
interface Language {
|
||||
langCode: string;
|
||||
|
|
@ -35,7 +29,6 @@ interface LangKey {
|
|||
langKey: string;
|
||||
description: string;
|
||||
isActive: string;
|
||||
categoryId?: number;
|
||||
}
|
||||
|
||||
interface LangText {
|
||||
|
|
@ -66,10 +59,6 @@ export default function I18nPage() {
|
|||
const [selectedLanguages, setSelectedLanguages] = useState<Set<string>>(new Set());
|
||||
const [activeTab, setActiveTab] = useState<"keys" | "languages">("keys");
|
||||
|
||||
// 카테고리 관련 상태
|
||||
const [selectedCategory, setSelectedCategory] = useState<LangCategory | null>(null);
|
||||
const [isGenerateModalOpen, setIsGenerateModalOpen] = useState(false);
|
||||
|
||||
const [companies, setCompanies] = useState<Array<{ code: string; name: string }>>([]);
|
||||
|
||||
// 회사 목록 조회
|
||||
|
|
@ -103,14 +92,9 @@ export default function I18nPage() {
|
|||
};
|
||||
|
||||
// 다국어 키 목록 조회
|
||||
const fetchLangKeys = async (categoryId?: number | null) => {
|
||||
const fetchLangKeys = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) {
|
||||
params.append("categoryId", categoryId.toString());
|
||||
}
|
||||
const url = `/multilang/keys${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
const response = await apiClient.get("/multilang/keys");
|
||||
const data = response.data;
|
||||
if (data.success) {
|
||||
setLangKeys(data.data);
|
||||
|
|
@ -487,13 +471,6 @@ export default function I18nPage() {
|
|||
initializeData();
|
||||
}, []);
|
||||
|
||||
// 카테고리 변경 시 키 목록 다시 조회
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
fetchLangKeys(selectedCategory?.categoryId);
|
||||
}
|
||||
}, [selectedCategory?.categoryId]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: "select",
|
||||
|
|
@ -701,70 +678,27 @@ export default function I18nPage() {
|
|||
|
||||
{/* 다국어 키 관리 탭 */}
|
||||
{activeTab === "keys" && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
{/* 좌측: 카테고리 트리 (2/12) */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="py-3">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-10">
|
||||
{/* 좌측: 언어 키 목록 (7/10) */}
|
||||
<Card className="lg:col-span-7">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">카테고리</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<ScrollArea className="h-[500px]">
|
||||
<CategoryTree
|
||||
selectedCategoryId={selectedCategory?.categoryId || null}
|
||||
onSelectCategory={(cat) => setSelectedCategory(cat)}
|
||||
onDoubleClickCategory={(cat) => {
|
||||
setSelectedCategory(cat);
|
||||
setIsGenerateModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 중앙: 언어 키 목록 (6/12) */}
|
||||
<Card className="lg:col-span-6">
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
언어 키 목록
|
||||
{selectedCategory && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedCategory.categoryName}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardTitle>언어 키 목록</CardTitle>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelectedKeys}
|
||||
disabled={selectedKeys.size === 0}
|
||||
>
|
||||
<Button variant="destructive" onClick={handleDeleteSelectedKeys} disabled={selectedKeys.size === 0}>
|
||||
선택 삭제 ({selectedKeys.size})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleAddKey}>
|
||||
수동 추가
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsGenerateModalOpen(true)}
|
||||
disabled={!selectedCategory}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
자동 생성
|
||||
</Button>
|
||||
<Button onClick={handleAddKey}>새 키 추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<CardContent>
|
||||
{/* 검색 필터 영역 */}
|
||||
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="company" className="text-xs">회사</Label>
|
||||
<Label htmlFor="company">회사</Label>
|
||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="전체 회사" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -779,22 +713,22 @@ export default function I18nPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="search" className="text-xs">검색</Label>
|
||||
<Label htmlFor="search">검색</Label>
|
||||
<Input
|
||||
placeholder="키명, 설명으로 검색..."
|
||||
placeholder="키명, 설명, 메뉴, 회사로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<div className="text-xs text-muted-foreground">결과: {getFilteredLangKeys().length}건</div>
|
||||
<div className="text-sm text-muted-foreground">검색 결과: {getFilteredLangKeys().length}건</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div>
|
||||
<div className="mb-2 text-sm text-muted-foreground">전체: {getFilteredLangKeys().length}건</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={getFilteredLangKeys()}
|
||||
|
|
@ -805,8 +739,8 @@ export default function I18nPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 우측: 선택된 키의 다국어 관리 (4/12) */}
|
||||
<Card className="lg:col-span-4">
|
||||
{/* 우측: 선택된 키의 다국어 관리 (3/10) */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedKey ? (
|
||||
|
|
@ -883,18 +817,6 @@ export default function I18nPage() {
|
|||
onSave={handleSaveLanguage}
|
||||
languageData={editingLanguage}
|
||||
/>
|
||||
|
||||
{/* 키 자동 생성 모달 */}
|
||||
<KeyGenerateModal
|
||||
isOpen={isGenerateModalOpen}
|
||||
onClose={() => setIsGenerateModalOpen(false)}
|
||||
selectedCategory={selectedCategory}
|
||||
companyCode={user?.companyCode || ""}
|
||||
isSuperAdmin={user?.companyCode === "*"}
|
||||
onSuccess={() => {
|
||||
fetchLangKeys(selectedCategory?.categoryId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
|
|
@ -93,13 +90,6 @@ export default function TableManagementPage() {
|
|||
// 🎯 Entity 조인 관련 상태
|
||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||
|
||||
// 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
|
||||
const [entityComboboxOpen, setEntityComboboxOpen] = useState<Record<string, {
|
||||
table: boolean;
|
||||
joinColumn: boolean;
|
||||
displayColumn: boolean;
|
||||
}>>({});
|
||||
|
||||
// DDL 기능 관련 상태
|
||||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||
|
|
@ -1398,266 +1388,113 @@ export default function TableManagementPage() {
|
|||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
{/* 참조 테이블 - 검색 가능한 Combobox */}
|
||||
<div className="w-56">
|
||||
{/* 참조 테이블 */}
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.table || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], table: open },
|
||||
}))
|
||||
<Select
|
||||
value={column.referenceTable || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={entityComboboxOpen[column.columnName]?.table || false}
|
||||
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
|
||||
: "테이블 선택..."}
|
||||
<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>
|
||||
{referenceTableOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.label} ${option.value}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity", option.value);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], table: false },
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceTableOptions.map((option, index) => (
|
||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-muted-foreground text-xs">{option.value}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조인 컬럼 - 검색 가능한 Combobox */}
|
||||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div className="w-56">
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: open },
|
||||
}))
|
||||
<Select
|
||||
value={column.referenceColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_reference_column",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
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}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||
<SelectItem
|
||||
key={`ref-col-${refCol.columnName}-${index}`}
|
||||
value={refCol.columnName}
|
||||
>
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||
<SelectItem value="loading" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
</span>
|
||||
) : column.referenceColumn && column.referenceColumn !== "none" ? (
|
||||
column.referenceColumn
|
||||
) : (
|
||||
"컬럼 선택..."
|
||||
)}
|
||||
<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={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_reference_column", "none");
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.referenceColumn === "none" || !column.referenceColumn ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
로딩중
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 컬럼 - 검색 가능한 Combobox */}
|
||||
{/* 표시 컬럼 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="w-56">
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: open },
|
||||
}))
|
||||
<Select
|
||||
value={column.displayColumn || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
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}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||
<SelectItem
|
||||
key={`ref-col-${refCol.columnName}-${index}`}
|
||||
value={refCol.columnName}
|
||||
>
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||
<SelectItem value="loading" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
</span>
|
||||
) : column.displayColumn && column.displayColumn !== "none" ? (
|
||||
column.displayColumn
|
||||
) : (
|
||||
"컬럼 선택..."
|
||||
)}
|
||||
<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={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_display_column", "none");
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === "none" || !column.displayColumn ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
로딩중
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1668,8 +1505,8 @@ export default function TableManagementPage() {
|
|||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<span>✓</span>
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,8 @@ function ScreenViewPage() {
|
|||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
|
||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||
const previewCompanyCode = searchParams.get("company_code");
|
||||
|
||||
// 프리뷰 모드 감지 (iframe에서 로드될 때)
|
||||
const isPreviewMode = searchParams.get("preview") === "true";
|
||||
|
||||
// 🆕 현재 로그인한 사용자 정보
|
||||
const { user, userName, companyCode: authCompanyCode } = useAuth();
|
||||
|
||||
// 프리뷰 모드에서는 URL 파라미터의 company_code 우선 사용
|
||||
const companyCode = previewCompanyCode || authCompanyCode;
|
||||
const { user, userName, companyCode } = useAuth();
|
||||
|
||||
// 🆕 모바일 환경 감지
|
||||
const { isMobile } = useResponsive();
|
||||
|
|
@ -113,7 +104,7 @@ function ScreenViewPage() {
|
|||
// 편집 모달 이벤트 리스너 등록
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
|
||||
setEditModalConfig({
|
||||
screenId: event.detail.screenId,
|
||||
|
|
@ -242,40 +233,27 @@ function ScreenViewPage() {
|
|||
const designWidth = layout?.screenResolution?.width || 1200;
|
||||
const designHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
|
||||
let containerWidth: number;
|
||||
let containerHeight: number;
|
||||
|
||||
if (isPreviewMode) {
|
||||
// iframe에서는 window 크기를 직접 사용
|
||||
containerWidth = window.innerWidth;
|
||||
containerHeight = window.innerHeight;
|
||||
} else {
|
||||
containerWidth = containerRef.current.offsetWidth;
|
||||
containerHeight = containerRef.current.offsetHeight;
|
||||
}
|
||||
// 컨테이너의 실제 크기
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const containerHeight = containerRef.current.offsetHeight;
|
||||
|
||||
let newScale: number;
|
||||
|
||||
if (isPreviewMode) {
|
||||
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
|
||||
const scaleX = containerWidth / designWidth;
|
||||
const scaleY = containerHeight / designHeight;
|
||||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
||||
} else {
|
||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
||||
// 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8)
|
||||
const MARGIN_X = 32;
|
||||
const availableWidth = containerWidth - MARGIN_X;
|
||||
newScale = availableWidth / designWidth;
|
||||
}
|
||||
|
||||
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
||||
const newScale = availableWidth / designWidth;
|
||||
|
||||
// console.log("📐 스케일 계산:", {
|
||||
// containerWidth,
|
||||
// containerHeight,
|
||||
// MARGIN_X,
|
||||
// availableWidth,
|
||||
// designWidth,
|
||||
// designHeight,
|
||||
// finalScale: newScale,
|
||||
// isPreviewMode,
|
||||
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||
// });
|
||||
|
||||
setScale(newScale);
|
||||
|
|
@ -294,7 +272,7 @@ function ScreenViewPage() {
|
|||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [layout, isMobile, isPreviewMode]);
|
||||
}, [layout, isMobile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -332,7 +310,7 @@ function ScreenViewPage() {
|
|||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div ref={containerRef} className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}>
|
||||
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import "@/app/globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "POP - 생산실적관리",
|
||||
description: "생산 현장 실적 관리 시스템",
|
||||
};
|
||||
|
||||
export default function PopLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { PopDashboard } from "@/components/pop/dashboard";
|
||||
|
||||
export default function PopPage() {
|
||||
return <PopDashboard />;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { PopApp } from "@/components/pop";
|
||||
|
||||
export default function PopWorkPage() {
|
||||
return <PopApp />;
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { PopApp } from "@/components/pop";
|
||||
|
||||
export default function PopWorkPage() {
|
||||
return <PopApp />;
|
||||
}
|
||||
|
||||
|
|
@ -388,18 +388,4 @@ select {
|
|||
border-spacing: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== 저장 테이블 막대기 애니메이션 ===== */
|
||||
@keyframes saveBarDrop {
|
||||
0% {
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
transform-origin: top;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== End of Global Styles ===== */
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronRight, ChevronDown, Folder, FolderOpen, Tag } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LangCategory, getCategories } from "@/lib/api/multilang";
|
||||
|
||||
interface CategoryTreeProps {
|
||||
selectedCategoryId: number | null;
|
||||
onSelectCategory: (category: LangCategory | null) => void;
|
||||
onDoubleClickCategory?: (category: LangCategory) => void;
|
||||
}
|
||||
|
||||
interface CategoryNodeProps {
|
||||
category: LangCategory;
|
||||
level: number;
|
||||
selectedCategoryId: number | null;
|
||||
onSelectCategory: (category: LangCategory) => void;
|
||||
onDoubleClickCategory?: (category: LangCategory) => void;
|
||||
}
|
||||
|
||||
function CategoryNode({
|
||||
category,
|
||||
level,
|
||||
selectedCategoryId,
|
||||
onSelectCategory,
|
||||
onDoubleClickCategory,
|
||||
}: CategoryNodeProps) {
|
||||
// 기본값: 접힌 상태로 시작
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasChildren = category.children && category.children.length > 0;
|
||||
const isSelected = selectedCategoryId === category.categoryId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||||
onClick={() => onSelectCategory(category)}
|
||||
onDoubleClick={() => onDoubleClickCategory?.(category)}
|
||||
>
|
||||
{/* 확장/축소 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
|
||||
{/* 폴더/태그 아이콘 */}
|
||||
{hasChildren || level === 0 ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
)
|
||||
) : (
|
||||
<Tag className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
)}
|
||||
|
||||
{/* 카테고리 이름 */}
|
||||
<span className="truncate">{category.categoryName}</span>
|
||||
|
||||
{/* prefix 표시 */}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs",
|
||||
isSelected ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{category.keyPrefix}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 자식 카테고리 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{category.children!.map((child) => (
|
||||
<CategoryNode
|
||||
key={child.categoryId}
|
||||
category={child}
|
||||
level={level + 1}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onSelectCategory={onSelectCategory}
|
||||
onDoubleClickCategory={onDoubleClickCategory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryTree({
|
||||
selectedCategoryId,
|
||||
onSelectCategory,
|
||||
onDoubleClickCategory,
|
||||
}: CategoryTreeProps) {
|
||||
const [categories, setCategories] = useState<LangCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getCategories();
|
||||
if (response.success && response.data) {
|
||||
setCategories(response.data);
|
||||
} else {
|
||||
setError(response.error?.details || "카테고리 로드 실패");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("카테고리 로드 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
카테고리 로딩 중...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
카테고리가 없습니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{/* 전체 선택 옵션 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
selectedCategoryId === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
onClick={() => onSelectCategory(null)}
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0" />
|
||||
<span>전체</span>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 트리 */}
|
||||
{categories.map((category) => (
|
||||
<CategoryNode
|
||||
key={category.categoryId}
|
||||
category={category}
|
||||
level={0}
|
||||
selectedCategoryId={selectedCategoryId}
|
||||
onSelectCategory={onSelectCategory}
|
||||
onDoubleClickCategory={onDoubleClickCategory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryTree;
|
||||
|
||||
|
||||
|
|
@ -1,497 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, AlertCircle, CheckCircle2, Info, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
LangCategory,
|
||||
Language,
|
||||
generateKey,
|
||||
previewKey,
|
||||
createOverrideKey,
|
||||
getLanguages,
|
||||
getCategoryPath,
|
||||
KeyPreview,
|
||||
} from "@/lib/api/multilang";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface Company {
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface KeyGenerateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedCategory: LangCategory | null;
|
||||
companyCode: string;
|
||||
isSuperAdmin: boolean;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function KeyGenerateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedCategory,
|
||||
companyCode,
|
||||
isSuperAdmin,
|
||||
onSuccess,
|
||||
}: KeyGenerateModalProps) {
|
||||
// 상태
|
||||
const [keyMeaning, setKeyMeaning] = useState("");
|
||||
const [usageNote, setUsageNote] = useState("");
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState(companyCode);
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [texts, setTexts] = useState<Record<string, string>>({});
|
||||
const [categoryPath, setCategoryPath] = useState<LangCategory[]>([]);
|
||||
const [preview, setPreview] = useState<KeyPreview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [companySearchOpen, setCompanySearchOpen] = useState(false);
|
||||
|
||||
// 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setKeyMeaning("");
|
||||
setUsageNote("");
|
||||
setTargetCompanyCode(isSuperAdmin ? "*" : companyCode);
|
||||
setTexts({});
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
loadLanguages();
|
||||
if (isSuperAdmin) {
|
||||
loadCompanies();
|
||||
}
|
||||
if (selectedCategory) {
|
||||
loadCategoryPath(selectedCategory.categoryId);
|
||||
} else {
|
||||
setCategoryPath([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, selectedCategory, companyCode, isSuperAdmin]);
|
||||
|
||||
// 회사 목록 로드 (최고관리자 전용)
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
if (response.data.success && response.data.data) {
|
||||
// snake_case를 camelCase로 변환하고 공통(*)은 제외
|
||||
const companyList = response.data.data
|
||||
.filter((c: any) => c.company_code !== "*")
|
||||
.map((c: any) => ({
|
||||
companyCode: c.company_code,
|
||||
companyName: c.company_name,
|
||||
}));
|
||||
setCompanies(companyList);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("회사 목록 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 언어 목록 로드
|
||||
const loadLanguages = async () => {
|
||||
const response = await getLanguages();
|
||||
if (response.success && response.data) {
|
||||
const activeLanguages = response.data.filter((l) => l.isActive === "Y");
|
||||
setLanguages(activeLanguages);
|
||||
// 초기 텍스트 상태 설정
|
||||
const initialTexts: Record<string, string> = {};
|
||||
activeLanguages.forEach((lang) => {
|
||||
initialTexts[lang.langCode] = "";
|
||||
});
|
||||
setTexts(initialTexts);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 경로 로드
|
||||
const loadCategoryPath = async (categoryId: number) => {
|
||||
const response = await getCategoryPath(categoryId);
|
||||
if (response.success && response.data) {
|
||||
setCategoryPath(response.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 키 미리보기 (디바운스)
|
||||
const loadPreview = useCallback(async () => {
|
||||
if (!selectedCategory || !keyMeaning.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const response = await previewKey(
|
||||
selectedCategory.categoryId,
|
||||
keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
|
||||
targetCompanyCode
|
||||
);
|
||||
if (response.success && response.data) {
|
||||
setPreview(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("키 미리보기 실패:", err);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [selectedCategory, keyMeaning, targetCompanyCode]);
|
||||
|
||||
// keyMeaning 변경 시 디바운스로 미리보기 로드
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(loadPreview, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [loadPreview]);
|
||||
|
||||
// 텍스트 변경 핸들러
|
||||
const handleTextChange = (langCode: string, value: string) => {
|
||||
setTexts((prev) => ({ ...prev, [langCode]: value }));
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!selectedCategory) {
|
||||
setError("카테고리를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keyMeaning.trim()) {
|
||||
setError("키 의미를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 최소 하나의 텍스트 입력 검증
|
||||
const hasText = Object.values(texts).some((t) => t.trim());
|
||||
if (!hasText) {
|
||||
setError("최소 하나의 언어에 대한 텍스트를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 오버라이드 모드인지 확인
|
||||
if (preview?.isOverride && preview.baseKeyId) {
|
||||
// 오버라이드 키 생성
|
||||
const response = await createOverrideKey({
|
||||
companyCode: targetCompanyCode,
|
||||
baseKeyId: preview.baseKeyId,
|
||||
texts: Object.entries(texts)
|
||||
.filter(([_, text]) => text.trim())
|
||||
.map(([langCode, langText]) => ({ langCode, langText })),
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
setError(response.error?.details || "오버라이드 키 생성 실패");
|
||||
}
|
||||
} else {
|
||||
// 새 키 생성
|
||||
const response = await generateKey({
|
||||
companyCode: targetCompanyCode,
|
||||
categoryId: selectedCategory.categoryId,
|
||||
keyMeaning: keyMeaning.trim().toLowerCase().replace(/\s+/g, "_"),
|
||||
usageNote: usageNote.trim() || undefined,
|
||||
texts: Object.entries(texts)
|
||||
.filter(([_, text]) => text.trim())
|
||||
.map(([langCode, langText]) => ({ langCode, langText })),
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
setError(response.error?.details || "키 생성 실패");
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "키 생성 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 생성될 키 미리보기
|
||||
const generatedKeyPreview = categoryPath.length > 0 && keyMeaning.trim()
|
||||
? [...categoryPath.map((c) => c.keyPrefix), keyMeaning.trim().toLowerCase().replace(/\s+/g, "_")].join(".")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{preview?.isOverride ? "오버라이드 키 생성" : "다국어 키 생성"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{preview?.isOverride
|
||||
? "공통 키에 대한 회사별 오버라이드를 생성합니다"
|
||||
: "새로운 다국어 키를 자동으로 생성합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 카테고리 경로 표시 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">카테고리</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{categoryPath.length > 0 ? (
|
||||
categoryPath.map((cat, idx) => (
|
||||
<span key={cat.categoryId} className="flex items-center">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{cat.categoryName}
|
||||
</Badge>
|
||||
{idx < categoryPath.length - 1 && (
|
||||
<span className="mx-1 text-muted-foreground">/</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
카테고리를 선택해주세요
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 키 의미 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="keyMeaning" className="text-xs sm:text-sm">
|
||||
키 의미 *
|
||||
</Label>
|
||||
<Input
|
||||
id="keyMeaning"
|
||||
value={keyMeaning}
|
||||
onChange={(e) => setKeyMeaning(e.target.value)}
|
||||
placeholder="예: add_new_item, search_button, save_success"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
영문 소문자와 밑줄(_)을 사용하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 생성될 키 미리보기 */}
|
||||
{generatedKeyPreview && (
|
||||
<div className={cn(
|
||||
"rounded-md border p-3",
|
||||
preview?.exists
|
||||
? "border-destructive bg-destructive/10"
|
||||
: preview?.isOverride
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-green-500 bg-green-500/10"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{previewLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : preview?.exists ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : preview?.isOverride ? (
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<code className="text-xs font-mono sm:text-sm">
|
||||
{generatedKeyPreview}
|
||||
</code>
|
||||
</div>
|
||||
{preview?.exists && (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
이미 존재하는 키입니다
|
||||
</p>
|
||||
)}
|
||||
{preview?.isOverride && !preview?.exists && (
|
||||
<p className="mt-1 text-xs text-blue-600">
|
||||
공통 키가 존재합니다. 회사별 오버라이드로 생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 대상 회사 선택 (최고 관리자만) */}
|
||||
{isSuperAdmin && (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">대상</Label>
|
||||
<div className="mt-1">
|
||||
<Popover open={companySearchOpen} onOpenChange={setCompanySearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={companySearchOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{targetCompanyCode === "*"
|
||||
? "공통 (*) - 모든 회사 적용"
|
||||
: companies.find((c) => c.companyCode === targetCompanyCode)
|
||||
? `${companies.find((c) => c.companyCode === targetCompanyCode)?.companyName} (${targetCompanyCode})`
|
||||
: "대상 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="회사 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs sm:text-sm">
|
||||
검색 결과가 없습니다
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="공통"
|
||||
onSelect={() => {
|
||||
setTargetCompanyCode("*");
|
||||
setCompanySearchOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetCompanyCode === "*" ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
공통 (*) - 모든 회사 적용
|
||||
</CommandItem>
|
||||
{companies.map((company) => (
|
||||
<CommandItem
|
||||
key={company.companyCode}
|
||||
value={`${company.companyName} ${company.companyCode}`}
|
||||
onSelect={() => {
|
||||
setTargetCompanyCode(company.companyCode);
|
||||
setCompanySearchOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetCompanyCode === company.companyCode ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{company.companyName} ({company.companyCode})
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사용 메모 */}
|
||||
<div>
|
||||
<Label htmlFor="usageNote" className="text-xs sm:text-sm">
|
||||
사용 메모 (선택)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="usageNote"
|
||||
value={usageNote}
|
||||
onChange={(e) => setUsageNote(e.target.value)}
|
||||
placeholder="이 키가 어디서 사용되는지 메모"
|
||||
className="h-16 resize-none text-xs sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 번역 텍스트 입력 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">번역 텍스트 *</Label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.langCode} className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="w-12 justify-center text-xs">
|
||||
{lang.langCode}
|
||||
</Badge>
|
||||
<Input
|
||||
value={texts[lang.langCode] || ""}
|
||||
onChange={(e) => handleTextChange(lang.langCode, e.target.value)}
|
||||
placeholder={`${lang.langName} 텍스트`}
|
||||
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs sm:text-sm">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading || !selectedCategory || !keyMeaning.trim() || preview?.exists}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : preview?.isOverride ? (
|
||||
"오버라이드 생성"
|
||||
) : (
|
||||
"키 생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyGenerateModal;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -174,24 +174,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 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); // 첫 번째 레코드를 원본으로 저장
|
||||
} else {
|
||||
setFormData(editData);
|
||||
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
}
|
||||
setFormData(editData);
|
||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||
} else {
|
||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||
|
|
|
|||
|
|
@ -65,10 +65,6 @@ const nodeTypes = {
|
|||
*/
|
||||
interface FlowEditorInnerProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
// 플로우 에디터 툴바 버튼 설정
|
||||
|
|
@ -91,7 +87,7 @@ const flowToolbarButtons: ToolbarButton[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorInnerProps) {
|
||||
function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition, setCenter } = useReactFlow();
|
||||
|
||||
|
|
@ -389,7 +385,7 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar validations={validations} onSaveComplete={onSaveComplete} />
|
||||
<FlowToolbar validations={validations} />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
|
@ -420,21 +416,13 @@ function FlowEditorInner({ initialFlowId, onSaveComplete, embedded = false }: Fl
|
|||
*/
|
||||
interface FlowEditorProps {
|
||||
initialFlowId?: number | null;
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
/** 임베디드 모드 여부 (헤더 표시 여부 등) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function FlowEditor({ initialFlowId, onSaveComplete, embedded = false }: FlowEditorProps = {}) {
|
||||
export function FlowEditor({ initialFlowId }: FlowEditorProps = {}) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner
|
||||
initialFlowId={initialFlowId}
|
||||
onSaveComplete={onSaveComplete}
|
||||
embedded={embedded}
|
||||
/>
|
||||
<FlowEditorInner initialFlowId={initialFlowId} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,11 +17,9 @@ import { useToast } from "@/hooks/use-toast";
|
|||
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
/** 임베디드 모드에서 저장 완료 시 호출되는 콜백 */
|
||||
onSaveComplete?: (flowId: number, flowName: string) => void;
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarProps) {
|
||||
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||
const { toast } = useToast();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
|
|
@ -61,27 +59,13 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "저장 완료",
|
||||
title: "✅ 플로우 저장 완료",
|
||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// 임베디드 모드에서 저장 완료 콜백 호출
|
||||
if (onSaveComplete && result.flowId) {
|
||||
onSaveComplete(result.flowId, flowName);
|
||||
}
|
||||
|
||||
// 부모 창이 있으면 postMessage로 알림 (새 창에서 열린 경우)
|
||||
if (window.opener && result.flowId) {
|
||||
window.opener.postMessage({
|
||||
type: "FLOW_SAVED",
|
||||
flowId: result.flowId,
|
||||
flowName: flowName,
|
||||
}, "*");
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
title: "❌ 저장 실패",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,13 +22,6 @@ const OPERATOR_LABELS: Record<string, string> = {
|
|||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
EXISTS_IN: "EXISTS IN",
|
||||
NOT_EXISTS_IN: "NOT EXISTS IN",
|
||||
};
|
||||
|
||||
// EXISTS 계열 연산자인지 확인
|
||||
const isExistsOperator = (operator: string): boolean => {
|
||||
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
|
|
@ -61,31 +54,15 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
|
|||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span
|
||||
className={`rounded px-1 py-0.5 ${
|
||||
isExistsOperator(condition.operator)
|
||||
? "bg-purple-200 text-purple-800"
|
||||
: "bg-yellow-200 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
|
||||
{isExistsOperator(condition.operator) ? (
|
||||
<span className="text-purple-600">
|
||||
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
|
||||
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
|
||||
{condition.value !== null && condition.value !== undefined && (
|
||||
<span className="text-gray-600">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
) : (
|
||||
// 일반 연산자인 경우 값 표시
|
||||
condition.value !== null &&
|
||||
condition.value !== undefined && (
|
||||
<span className="text-gray-600">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,14 +28,6 @@ const OPERATOR_LABELS: Record<string, string> = {
|
|||
"%": "%",
|
||||
};
|
||||
|
||||
// 피연산자를 문자열로 변환
|
||||
function getOperandStr(operand: any): string {
|
||||
if (!operand) return "?";
|
||||
if (operand.type === "static") return String(operand.value || "?");
|
||||
if (operand.fieldLabel) return operand.fieldLabel;
|
||||
return operand.field || operand.resultField || "?";
|
||||
}
|
||||
|
||||
// 수식 요약 생성
|
||||
function getFormulaSummary(transformation: FormulaTransformNodeData["transformations"][0]): string {
|
||||
const { formulaType, arithmetic, function: func, condition, staticValue } = transformation;
|
||||
|
|
@ -43,19 +35,11 @@ function getFormulaSummary(transformation: FormulaTransformNodeData["transformat
|
|||
switch (formulaType) {
|
||||
case "arithmetic": {
|
||||
if (!arithmetic) return "미설정";
|
||||
const leftStr = getOperandStr(arithmetic.leftOperand);
|
||||
const rightStr = getOperandStr(arithmetic.rightOperand);
|
||||
let formula = `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
|
||||
// 추가 연산 표시
|
||||
if (arithmetic.additionalOperations && arithmetic.additionalOperations.length > 0) {
|
||||
for (const addOp of arithmetic.additionalOperations) {
|
||||
const opStr = getOperandStr(addOp.operand);
|
||||
formula += ` ${OPERATOR_LABELS[addOp.operator] || addOp.operator} ${opStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
return formula;
|
||||
const left = arithmetic.leftOperand;
|
||||
const right = arithmetic.rightOperand;
|
||||
const leftStr = left.type === "static" ? left.value : `${left.type}.${left.field || left.resultField}`;
|
||||
const rightStr = right.type === "static" ? right.value : `${right.type}.${right.field || right.resultField}`;
|
||||
return `${leftStr} ${OPERATOR_LABELS[arithmetic.operator]} ${rightStr}`;
|
||||
}
|
||||
case "function": {
|
||||
if (!func) return "미설정";
|
||||
|
|
|
|||
|
|
@ -4,18 +4,14 @@
|
|||
* 조건 분기 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
// 필드 정의
|
||||
interface FieldDefinition {
|
||||
|
|
@ -24,19 +20,6 @@ interface FieldDefinition {
|
|||
type?: string;
|
||||
}
|
||||
|
||||
// 테이블 정보
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
}
|
||||
|
||||
// 테이블 컬럼 정보
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
interface ConditionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ConditionNodeData;
|
||||
|
|
@ -55,194 +38,8 @@ const OPERATORS = [
|
|||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
{ value: "IS_NULL", label: "NULL" },
|
||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||
{ value: "EXISTS_IN", label: "다른 테이블에 존재함" },
|
||||
{ value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
|
||||
] as const;
|
||||
|
||||
// EXISTS 계열 연산자인지 확인
|
||||
const isExistsOperator = (operator: string): boolean => {
|
||||
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||
};
|
||||
|
||||
// 테이블 선택용 검색 가능한 Combobox
|
||||
function TableCombobox({
|
||||
tables,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "테이블 검색...",
|
||||
}: {
|
||||
tables: TableInfo[];
|
||||
value: string;
|
||||
onSelect: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedTable = tables.find((t) => t.tableName === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedTable ? (
|
||||
<span className="truncate">
|
||||
{selectedTable.tableLabel}
|
||||
<span className="ml-1 text-gray-400">({selectedTable.tableName})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블 선택</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={placeholder} className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableLabel} ${table.tableName}`}
|
||||
onSelect={() => {
|
||||
onSelect(table.tableName);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel}</span>
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 컬럼 선택용 검색 가능한 Combobox
|
||||
function ColumnCombobox({
|
||||
columns,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "컬럼 검색...",
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
value: string;
|
||||
onSelect: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedColumn = columns.find((c) => c.columnName === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedColumn ? (
|
||||
<span className="truncate">
|
||||
{selectedColumn.columnLabel}
|
||||
<span className="ml-1 text-gray-400">({selectedColumn.columnName})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">컬럼 선택</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={placeholder} className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||
{columns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={`${col.columnLabel} ${col.columnName}`}
|
||||
onSelect={() => {
|
||||
onSelect(col.columnName);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{col.columnLabel}</span>
|
||||
<span className="ml-1 text-[10px] text-gray-400">({col.columnName})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 컬럼 선택 섹션 (자동 로드 포함)
|
||||
function ColumnSelectSection({
|
||||
lookupTable,
|
||||
lookupField,
|
||||
tableColumnsCache,
|
||||
loadingColumns,
|
||||
loadTableColumns,
|
||||
onSelect,
|
||||
}: {
|
||||
lookupTable: string;
|
||||
lookupField: string;
|
||||
tableColumnsCache: Record<string, ColumnInfo[]>;
|
||||
loadingColumns: Record<string, boolean>;
|
||||
loadTableColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
// 캐시에 없고 로딩 중이 아니면 자동으로 로드
|
||||
useEffect(() => {
|
||||
if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
|
||||
loadTableColumns(lookupTable);
|
||||
}
|
||||
}, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
|
||||
|
||||
const isLoading = loadingColumns[lookupTable];
|
||||
const columns = tableColumnsCache[lookupTable];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
<Search className="mr-1 inline h-3 w-3" />
|
||||
비교할 컬럼
|
||||
</Label>
|
||||
{isLoading ? (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
컬럼 목록 로딩 중...
|
||||
</div>
|
||||
) : columns && columns.length > 0 ? (
|
||||
<ColumnCombobox columns={columns} value={lookupField} onSelect={onSelect} placeholder="컬럼 검색..." />
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
컬럼 목록을 로드할 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
|
|
@ -251,12 +48,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// EXISTS 연산자용 상태
|
||||
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "조건 분기");
|
||||
|
|
@ -264,100 +55,6 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
setLogic(data.logic || "AND");
|
||||
}, [data]);
|
||||
|
||||
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
// 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
|
||||
if (allTables.length > 0) return;
|
||||
|
||||
// EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
|
||||
const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
|
||||
if (!hasExistsOperator) return;
|
||||
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName,
|
||||
tableLabel: t.tableLabel || t.tableName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllTables();
|
||||
}, [conditions, allTables.length]);
|
||||
|
||||
// 테이블 컬럼 로드 함수
|
||||
const loadTableColumns = useCallback(
|
||||
async (tableName: string): Promise<ColumnInfo[]> => {
|
||||
// 캐시에 있으면 반환
|
||||
if (tableColumnsCache[tableName]) {
|
||||
return tableColumnsCache[tableName];
|
||||
}
|
||||
|
||||
// 이미 로딩 중이면 스킵
|
||||
if (loadingColumns[tableName]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 로딩 상태 설정
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||
|
||||
try {
|
||||
// getColumnList 반환: { success, data: { columns, total, ... } }
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data && response.data.columns) {
|
||||
const columns = response.data.columns.map((c: any) => ({
|
||||
columnName: c.columnName,
|
||||
columnLabel: c.columnLabel || c.columnName,
|
||||
dataType: c.dataType,
|
||||
}));
|
||||
setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
|
||||
return columns;
|
||||
} else {
|
||||
console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||
} finally {
|
||||
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[tableColumnsCache, loadingColumns]
|
||||
);
|
||||
|
||||
// EXISTS 연산자 선택 시 테이블 목록 강제 로드
|
||||
const ensureTablesLoaded = useCallback(async () => {
|
||||
if (allTables.length > 0) return;
|
||||
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName,
|
||||
tableLabel: t.tableLabel || t.tableName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, [allTables.length]);
|
||||
|
||||
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
||||
|
|
@ -473,18 +170,15 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
const newCondition = {
|
||||
field: "",
|
||||
operator: "EQUALS" as ConditionOperator,
|
||||
value: "",
|
||||
valueType: "static" as "static" | "field",
|
||||
// EXISTS 연산자용 필드는 초기값 없음
|
||||
lookupTable: undefined,
|
||||
lookupTableLabel: undefined,
|
||||
lookupField: undefined,
|
||||
lookupFieldLabel: undefined,
|
||||
};
|
||||
setConditions([...conditions, newCondition]);
|
||||
setConditions([
|
||||
...conditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
|
|
@ -502,50 +196,9 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = async (index: number, field: string, value: any) => {
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
|
||||
// EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
|
||||
if (field === "operator" && isExistsOperator(value)) {
|
||||
await ensureTablesLoaded();
|
||||
// EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
|
||||
newConditions[index].value = "";
|
||||
newConditions[index].valueType = undefined;
|
||||
}
|
||||
|
||||
// EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
|
||||
if (field === "operator" && !isExistsOperator(value)) {
|
||||
newConditions[index].lookupTable = undefined;
|
||||
newConditions[index].lookupTableLabel = undefined;
|
||||
newConditions[index].lookupField = undefined;
|
||||
newConditions[index].lookupFieldLabel = undefined;
|
||||
}
|
||||
|
||||
// lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
|
||||
if (field === "lookupTable" && value) {
|
||||
const tableInfo = allTables.find((t) => t.tableName === value);
|
||||
if (tableInfo) {
|
||||
newConditions[index].lookupTableLabel = tableInfo.tableLabel;
|
||||
}
|
||||
// 테이블 변경 시 필드 초기화
|
||||
newConditions[index].lookupField = undefined;
|
||||
newConditions[index].lookupFieldLabel = undefined;
|
||||
// 컬럼 목록 미리 로드
|
||||
await loadTableColumns(value);
|
||||
}
|
||||
|
||||
// lookupField 변경 시 라벨 설정
|
||||
if (field === "lookupField" && value) {
|
||||
const tableName = newConditions[index].lookupTable;
|
||||
if (tableName && tableColumnsCache[tableName]) {
|
||||
const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
|
||||
if (columnInfo) {
|
||||
newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, {
|
||||
conditions: newConditions,
|
||||
|
|
@ -676,114 +329,64 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
|
||||
{isExistsOperator(condition.operator) && (
|
||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
<Database className="mr-1 inline h-3 w-3" />
|
||||
조회할 테이블
|
||||
</Label>
|
||||
{loadingTables ? (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
테이블 목록 로딩 중...
|
||||
</div>
|
||||
) : allTables.length > 0 ? (
|
||||
<TableCombobox
|
||||
tables={allTables}
|
||||
value={(condition as any).lookupTable || ""}
|
||||
onSelect={(value) => handleConditionChange(index, "lookupTable", value)}
|
||||
placeholder="테이블 검색..."
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
테이블 목록을 로드할 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||
<Select
|
||||
value={(condition as any).valueType || "static"}
|
||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(condition as any).lookupTable && (
|
||||
<ColumnSelectSection
|
||||
lookupTable={(condition as any).lookupTable}
|
||||
lookupField={(condition as any).lookupField || ""}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
loadingColumns={loadingColumns}
|
||||
loadTableColumns={loadTableColumns}
|
||||
onSelect={(value) => handleConditionChange(index, "lookupField", value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rounded bg-purple-50 p-2 text-xs text-purple-700">
|
||||
{condition.operator === "EXISTS_IN"
|
||||
? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
|
||||
: `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="비교할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 고정값: 직접 입력
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 일반 연산자인 경우: 기존 비교값 UI */}
|
||||
{condition.operator !== "IS_NULL" &&
|
||||
condition.operator !== "IS_NOT_NULL" &&
|
||||
!isExistsOperator(condition.operator) && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||
<Select
|
||||
value={(condition as any).valueType || "static"}
|
||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="비교할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && (
|
||||
<span className="ml-2 text-xs text-gray-400">({field.type})</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 고정값: 직접 입력
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -799,28 +402,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
<strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||
🔌 <strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||
</div>
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
<strong>비교 값 타입</strong>:<br />
|
||||
- <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||
<br />- <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||
</div>
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
<strong>테이블 존재 여부 검사</strong>:<br />
|
||||
- <strong>다른 테이블에 존재함</strong>: 값이 다른 테이블에 있으면 TRUE
|
||||
<br />- <strong>다른 테이블에 존재하지 않음</strong>: 값이 다른 테이블에 없으면 TRUE
|
||||
<br />
|
||||
(예: 품명이 품목정보 테이블에 없으면 자동 등록)
|
||||
🔄 <strong>비교 값 타입</strong>:<br />• <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||
<br />• <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
<strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
<strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* DELETE 액션 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -24,12 +24,6 @@ interface DeleteActionPropertiesProps {
|
|||
data: DeleteActionNodeData;
|
||||
}
|
||||
|
||||
// 소스 필드 타입
|
||||
interface SourceField {
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "=" },
|
||||
{ value: "NOT_EQUALS", label: "≠" },
|
||||
|
|
@ -40,7 +34,7 @@ const OPERATORS = [
|
|||
] as const;
|
||||
|
||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||
const { updateNode, getExternalConnectionsCache, nodes, edges } = useFlowEditorStore();
|
||||
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
|
@ -49,10 +43,6 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
|
||||
// 🆕 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<SourceField[]>([]);
|
||||
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
|
|
@ -134,106 +124,8 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
// whereConditions 변경 시 fieldOpenState 초기화
|
||||
useEffect(() => {
|
||||
setFieldOpenState(new Array(whereConditions.length).fill(false));
|
||||
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
|
||||
}, [whereConditions.length]);
|
||||
|
||||
// 🆕 소스 필드 로딩 (연결된 입력 노드에서)
|
||||
const loadSourceFields = useCallback(async () => {
|
||||
// 현재 노드로 연결된 엣지 찾기
|
||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||
console.log("🔍 DELETE 노드 연결 엣지:", incomingEdges);
|
||||
|
||||
if (incomingEdges.length === 0) {
|
||||
console.log("⚠️ 연결된 소스 노드가 없습니다");
|
||||
setSourceFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fields: SourceField[] = [];
|
||||
const processedFields = new Set<string>();
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
console.log("🔗 소스 노드:", sourceNode.type, sourceNode.data);
|
||||
|
||||
// 소스 노드 타입에 따라 필드 추출
|
||||
if (sourceNode.type === "trigger" && sourceNode.data.tableName) {
|
||||
// 트리거 노드: 테이블 컬럼 조회
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||||
if (columns && Array.isArray(columns)) {
|
||||
columns.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (!processedFields.has(colName)) {
|
||||
processedFields.add(colName);
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: col.columnLabel || col.column_label || colName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("트리거 노드 컬럼 로딩 실패:", error);
|
||||
}
|
||||
} else if (sourceNode.type === "tableSource" && sourceNode.data.tableName) {
|
||||
// 테이블 소스 노드
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(sourceNode.data.tableName);
|
||||
if (columns && Array.isArray(columns)) {
|
||||
columns.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (!processedFields.has(colName)) {
|
||||
processedFields.add(colName);
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: col.columnLabel || col.column_label || colName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 소스 노드 컬럼 로딩 실패:", error);
|
||||
}
|
||||
} else if (sourceNode.type === "condition") {
|
||||
// 조건 노드: 연결된 이전 노드에서 필드 가져오기
|
||||
const conditionIncomingEdges = edges.filter((e) => e.target === sourceNode.id);
|
||||
for (const condEdge of conditionIncomingEdges) {
|
||||
const condSourceNode = nodes.find((n) => n.id === condEdge.source);
|
||||
if (condSourceNode?.type === "trigger" && condSourceNode.data.tableName) {
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(condSourceNode.data.tableName);
|
||||
if (columns && Array.isArray(columns)) {
|
||||
columns.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
if (!processedFields.has(colName)) {
|
||||
processedFields.add(colName);
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: col.columnLabel || col.column_label || colName,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건 노드 소스 컬럼 로딩 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ DELETE 노드 소스 필드:", fields);
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 소스 필드 로딩
|
||||
useEffect(() => {
|
||||
loadSourceFields();
|
||||
}, [loadSourceFields]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
|
@ -347,41 +239,22 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
sourceField: undefined,
|
||||
staticValue: undefined,
|
||||
},
|
||||
];
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||||
|
||||
// 자동 저장
|
||||
updateNode(nodeId, {
|
||||
whereConditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
const newConditions = whereConditions.filter((_, i) => i !== index);
|
||||
setWhereConditions(newConditions);
|
||||
setFieldOpenState(new Array(newConditions.length).fill(false));
|
||||
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
|
||||
|
||||
// 자동 저장
|
||||
updateNode(nodeId, {
|
||||
whereConditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setWhereConditions(newConditions);
|
||||
|
||||
// 자동 저장
|
||||
updateNode(nodeId, {
|
||||
whereConditions: newConditions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
|
|
@ -967,125 +840,14 @@ export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesP
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 🆕 소스 필드 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드 (선택)</Label>
|
||||
{sourceFields.length > 0 ? (
|
||||
<Popover
|
||||
open={sourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...sourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={sourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{condition.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === condition.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || condition.sourceField}
|
||||
</span>
|
||||
{field?.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "소스 필드 선택 (선택)"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="_NONE_"
|
||||
onSelect={() => {
|
||||
handleConditionChange(index, "sourceField", undefined);
|
||||
const newState = [...sourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs text-gray-400 sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!condition.sourceField ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
없음 (정적 값 사용)
|
||||
</CommandItem>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleConditionChange(index, "sourceField", currentValue);
|
||||
const newState = [...sourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed border-gray-300 bg-gray-50 p-2 text-center text-xs text-gray-500">
|
||||
연결된 소스 노드가 없습니다
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-400">소스 데이터에서 값을 가져올 필드</p>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값</Label>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.staticValue || condition.value || ""}
|
||||
onChange={(e) => {
|
||||
handleConditionChange(index, "staticValue", e.target.value || undefined);
|
||||
handleConditionChange(index, "value", e.target.value);
|
||||
}}
|
||||
placeholder="비교할 고정 값"
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -797,85 +797,6 @@ export function FormulaTransformProperties({ nodeId, data }: FormulaTransformPro
|
|||
index,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 연산 목록 */}
|
||||
{trans.arithmetic.additionalOperations && trans.arithmetic.additionalOperations.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<Label className="text-xs text-gray-500">추가 연산</Label>
|
||||
{trans.arithmetic.additionalOperations.map((addOp: any, addIndex: number) => (
|
||||
<div key={addIndex} className="flex items-center gap-2 rounded bg-orange-50 p-2">
|
||||
<Select
|
||||
value={addOp.operator}
|
||||
onValueChange={(value) => {
|
||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operator: value };
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ARITHMETIC_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
{renderOperandSelector(
|
||||
addOp.operand,
|
||||
(updates) => {
|
||||
const newAdditionalOps = [...(trans.arithmetic!.additionalOperations || [])];
|
||||
newAdditionalOps[addIndex] = { ...newAdditionalOps[addIndex], operand: updates };
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
},
|
||||
index,
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const newAdditionalOps = trans.arithmetic!.additionalOperations!.filter(
|
||||
(_: any, i: number) => i !== addIndex
|
||||
);
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 연산 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={() => {
|
||||
const newAdditionalOps = [
|
||||
...(trans.arithmetic!.additionalOperations || []),
|
||||
{ operator: "*", operand: { type: "static" as const, value: "" } },
|
||||
];
|
||||
handleTransformationChange(index, {
|
||||
arithmetic: { ...trans.arithmetic!, additionalOperations: newAdditionalOps },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연산 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2, Sparkles } from "lucide-react";
|
||||
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -18,8 +18,6 @@ import { cn } from "@/lib/utils";
|
|||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||||
import type { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
|
|
@ -91,11 +89,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
|
||||
|
||||
// 🔥 채번 규칙 관련 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
|
||||
const [mappingNumberingRulesOpenState, setMappingNumberingRulesOpenState] = useState<boolean[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.targetTable);
|
||||
|
|
@ -135,33 +128,8 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
useEffect(() => {
|
||||
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
|
||||
setMappingNumberingRulesOpenState(new Array(fieldMappings.length).fill(false));
|
||||
}, [fieldMappings.length]);
|
||||
|
||||
// 🔥 채번 규칙 로딩 (자동 생성 사용 시)
|
||||
useEffect(() => {
|
||||
const loadNumberingRules = async () => {
|
||||
setNumberingRulesLoading(true);
|
||||
try {
|
||||
const response = await getNumberingRules();
|
||||
if (response.success && response.data) {
|
||||
setNumberingRules(response.data);
|
||||
console.log(`✅ 채번 규칙 ${response.data.length}개 로딩 완료`);
|
||||
} else {
|
||||
console.error("❌ 채번 규칙 로딩 실패:", response.error);
|
||||
setNumberingRules([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 채번 규칙 로딩 오류:", error);
|
||||
setNumberingRules([]);
|
||||
} finally {
|
||||
setNumberingRulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 🔥 외부 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
|
|
@ -572,7 +540,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
sourceField: null,
|
||||
targetField: "",
|
||||
staticValue: undefined,
|
||||
valueType: "source" as const, // 🔥 기본값: 소스 필드
|
||||
},
|
||||
];
|
||||
setFieldMappings(newMappings);
|
||||
|
|
@ -581,7 +548,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
// Combobox 열림 상태 배열 초기화
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
|
|
@ -592,7 +558,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
// Combobox 열림 상태 배열도 업데이트
|
||||
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
|
||||
setMappingNumberingRulesOpenState(new Array(newMappings.length).fill(false));
|
||||
};
|
||||
|
||||
const handleMappingChange = (index: number, field: string, value: any) => {
|
||||
|
|
@ -621,24 +586,6 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
targetField: value,
|
||||
targetFieldLabel: targetColumn?.label_ko || targetColumn?.column_label || targetColumn?.displayName || value,
|
||||
};
|
||||
} else if (field === "valueType") {
|
||||
// 🔥 값 생성 유형 변경 시 관련 필드 초기화
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
valueType: value,
|
||||
// 유형 변경 시 다른 유형의 값 초기화
|
||||
...(value !== "source" && { sourceField: null, sourceFieldLabel: undefined }),
|
||||
...(value !== "static" && { staticValue: undefined }),
|
||||
...(value !== "autoGenerate" && { numberingRuleId: undefined, numberingRuleName: undefined }),
|
||||
};
|
||||
} else if (field === "numberingRuleId") {
|
||||
// 🔥 채번 규칙 선택 시 이름도 함께 저장
|
||||
const selectedRule = numberingRules.find((r) => r.ruleId === value);
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
numberingRuleId: value,
|
||||
numberingRuleName: selectedRule?.ruleName,
|
||||
};
|
||||
} else {
|
||||
newMappings[index] = {
|
||||
...newMappings[index],
|
||||
|
|
@ -1218,203 +1165,54 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 🔥 값 생성 유형 선택 */}
|
||||
{/* 소스 필드 입력/선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값 생성 방식</Label>
|
||||
<div className="mt-1 grid grid-cols-3 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMappingChange(index, "valueType", "source")}
|
||||
className={cn(
|
||||
"rounded border px-2 py-1 text-xs transition-all",
|
||||
(mapping.valueType === "source" || !mapping.valueType)
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
소스 필드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMappingChange(index, "valueType", "static")}
|
||||
className={cn(
|
||||
"rounded border px-2 py-1 text-xs transition-all",
|
||||
mapping.valueType === "static"
|
||||
? "border-orange-500 bg-orange-50 text-orange-700"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
고정값
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMappingChange(index, "valueType", "autoGenerate")}
|
||||
className={cn(
|
||||
"rounded border px-2 py-1 text-xs transition-all flex items-center justify-center gap-1",
|
||||
mapping.valueType === "autoGenerate"
|
||||
? "border-purple-500 bg-purple-50 text-purple-700"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
자동생성
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🔥 소스 필드 입력/선택 (valueType === "source" 일 때만) */}
|
||||
{(mapping.valueType === "source" || !mapping.valueType) && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
소스 필드
|
||||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||
</Label>
|
||||
{hasRestAPISource ? (
|
||||
// REST API 소스인 경우: 직접 입력
|
||||
<Input
|
||||
value={mapping.sourceField || ""}
|
||||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||
placeholder="필드명 입력 (예: userId, userName)"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
) : (
|
||||
// 일반 소스인 경우: Combobox 선택
|
||||
<Popover
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{field?.label || mapping.sourceField}
|
||||
</span>
|
||||
{field?.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "소스 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
필드를 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{hasRestAPISource && (
|
||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 고정값 입력 (valueType === "static" 일 때) */}
|
||||
{mapping.valueType === "static" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">고정값</Label>
|
||||
<Label className="text-xs text-gray-600">
|
||||
소스 필드
|
||||
{hasRestAPISource && <span className="ml-1 text-teal-600">(REST API - 직접 입력)</span>}
|
||||
</Label>
|
||||
{hasRestAPISource ? (
|
||||
// REST API 소스인 경우: 직접 입력
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="고정값 입력"
|
||||
value={mapping.sourceField || ""}
|
||||
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
|
||||
placeholder="필드명 입력 (예: userId, userName)"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 채번 규칙 선택 (valueType === "autoGenerate" 일 때) */}
|
||||
{mapping.valueType === "autoGenerate" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
채번 규칙
|
||||
{numberingRulesLoading && <span className="ml-1 text-gray-400">(로딩 중...)</span>}
|
||||
</Label>
|
||||
) : (
|
||||
// 일반 소스인 경우: Combobox 선택
|
||||
<Popover
|
||||
open={mappingNumberingRulesOpenState[index]}
|
||||
open={mappingSourceFieldsOpenState[index]}
|
||||
onOpenChange={(open) => {
|
||||
const newState = [...mappingNumberingRulesOpenState];
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = open;
|
||||
setMappingNumberingRulesOpenState(newState);
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={mappingNumberingRulesOpenState[index]}
|
||||
aria-expanded={mappingSourceFieldsOpenState[index]}
|
||||
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={numberingRulesLoading || numberingRules.length === 0}
|
||||
>
|
||||
{mapping.numberingRuleId
|
||||
{mapping.sourceField
|
||||
? (() => {
|
||||
const rule = numberingRules.find((r) => r.ruleId === mapping.numberingRuleId);
|
||||
const field = sourceFields.find((f) => f.name === mapping.sourceField);
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
<Sparkles className="h-3 w-3 text-purple-500" />
|
||||
<div className="flex items-center justify-between gap-2 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{rule?.ruleName || mapping.numberingRuleName || mapping.numberingRuleId}
|
||||
{field?.label || mapping.sourceField}
|
||||
</span>
|
||||
{field?.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "채번 규칙 선택"}
|
||||
: "소스 필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -1424,36 +1222,37 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandInput placeholder="소스 필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
채번 규칙을 찾을 수 없습니다.
|
||||
필드를 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{numberingRules.map((rule) => (
|
||||
{sourceFields.map((field) => (
|
||||
<CommandItem
|
||||
key={rule.ruleId}
|
||||
value={rule.ruleId}
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
onSelect={(currentValue) => {
|
||||
handleMappingChange(index, "numberingRuleId", currentValue);
|
||||
const newState = [...mappingNumberingRulesOpenState];
|
||||
handleMappingChange(index, "sourceField", currentValue || null);
|
||||
const newState = [...mappingSourceFieldsOpenState];
|
||||
newState[index] = false;
|
||||
setMappingNumberingRulesOpenState(newState);
|
||||
setMappingSourceFieldsOpenState(newState);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
mapping.numberingRuleId === rule.ruleId ? "opacity-100" : "opacity-0",
|
||||
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{rule.ruleName}</span>
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{rule.ruleId}
|
||||
{rule.tableName && ` - ${rule.tableName}`}
|
||||
</span>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-[10px]">
|
||||
{field.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
|
@ -1462,13 +1261,11 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{numberingRules.length === 0 && !numberingRulesLoading && (
|
||||
<p className="mt-1 text-xs text-orange-600">
|
||||
등록된 채번 규칙이 없습니다. 시스템 관리에서 먼저 채번 규칙을 생성하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{hasRestAPISource && (
|
||||
<p className="mt-1 text-xs text-gray-500">API 응답 JSON의 필드명을 입력하세요</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<ArrowRight className="h-4 w-4 text-green-600" />
|
||||
|
|
@ -1603,6 +1400,18 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 정적 값 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
||||
<Input
|
||||
value={mapping.staticValue || ""}
|
||||
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
|
||||
placeholder="소스 필드 대신 고정 값 사용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">소스 필드가 비어있을 때만 사용됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1619,8 +1428,9 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
|||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
<p>테이블과 필드는 실제 데이터베이스에서 조회됩니다.</p>
|
||||
<p className="mt-1">값 생성 방식: 소스 필드(입력값 연결) / 고정값(직접 입력) / 자동생성(채번 규칙)</p>
|
||||
✅ 테이블과 필드는 실제 데이터베이스에서 조회됩니다.
|
||||
<br />
|
||||
💡 소스 필드가 없으면 정적 값이 사용됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -302,9 +302,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
|
||||
const isPreviewMode = searchParams.get("preview") === "true";
|
||||
|
||||
// 현재 모드에 따라 표시할 메뉴 결정
|
||||
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
||||
|
|
@ -461,15 +458,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UI 변환된 메뉴 데이터
|
||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -16,14 +15,6 @@ import { Camera, X, Car, Wrench, Clock, Plus, Trash2 } from "lucide-react";
|
|||
import { ProfileFormData } from "@/types/profile";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { VehicleRegisterData } from "@/lib/api/driver";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 언어 정보 타입
|
||||
interface LanguageInfo {
|
||||
langCode: string;
|
||||
langName: string;
|
||||
langNative: string;
|
||||
}
|
||||
|
||||
// 운전자 정보 타입
|
||||
export interface DriverInfo {
|
||||
|
|
@ -157,46 +148,6 @@ export function ProfileModal({
|
|||
onSave,
|
||||
onAlertClose,
|
||||
}: ProfileModalProps) {
|
||||
// 언어 목록 상태
|
||||
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
|
||||
|
||||
// 언어 목록 로드
|
||||
useEffect(() => {
|
||||
const loadLanguages = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/multilang/languages");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
// is_active가 'Y'인 언어만 필터링하고 정렬
|
||||
const activeLanguages = response.data.data
|
||||
.filter((lang: any) => lang.isActive === "Y" || lang.is_active === "Y")
|
||||
.map((lang: any) => ({
|
||||
langCode: lang.langCode || lang.lang_code,
|
||||
langName: lang.langName || lang.lang_name,
|
||||
langNative: lang.langNative || lang.lang_native,
|
||||
}))
|
||||
.sort((a: LanguageInfo, b: LanguageInfo) => {
|
||||
// KR을 먼저 표시
|
||||
if (a.langCode === "KR") return -1;
|
||||
if (b.langCode === "KR") return 1;
|
||||
return a.langCode.localeCompare(b.langCode);
|
||||
});
|
||||
setLanguages(activeLanguages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("언어 목록 로드 실패:", error);
|
||||
// 기본값 설정
|
||||
setLanguages([
|
||||
{ langCode: "KR", langName: "Korean", langNative: "한국어" },
|
||||
{ langCode: "US", langName: "English", langNative: "English" },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
loadLanguages();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 차량 상태 한글 변환
|
||||
const getStatusLabel = (status: string | null) => {
|
||||
switch (status) {
|
||||
|
|
@ -342,15 +293,10 @@ export function ProfileModal({
|
|||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.length > 0 ? (
|
||||
languages.map((lang) => (
|
||||
<SelectItem key={lang.langCode} value={lang.langCode}>
|
||||
{lang.langNative} ({lang.langCode})
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
||||
)}
|
||||
<SelectItem value="KR">한국어 (KR)</SelectItem>
|
||||
<SelectItem value="US">English (US)</SelectItem>
|
||||
<SelectItem value="JP">日本語 (JP)</SelectItem>
|
||||
<SelectItem value="CN">中文 (CN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X, Info } from "lucide-react";
|
||||
import { WorkOrder } from "./types";
|
||||
|
||||
interface PopAcceptModalProps {
|
||||
isOpen: boolean;
|
||||
workOrder: WorkOrder | null;
|
||||
quantity: number;
|
||||
onQuantityChange: (qty: number) => void;
|
||||
onConfirm: (quantity: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PopAcceptModal({
|
||||
isOpen,
|
||||
workOrder,
|
||||
quantity,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: PopAcceptModalProps) {
|
||||
if (!isOpen || !workOrder) return null;
|
||||
|
||||
const acceptedQty = workOrder.acceptedQuantity || 0;
|
||||
const remainingQty = workOrder.orderQuantity - acceptedQty;
|
||||
|
||||
const handleAdjust = (delta: number) => {
|
||||
const newQty = Math.max(1, Math.min(quantity + delta, remainingQty));
|
||||
onQuantityChange(newQty);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const newQty = Math.max(0, Math.min(val, remainingQty));
|
||||
onQuantityChange(newQty);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (quantity > 0) {
|
||||
onConfirm(quantity);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="pop-modal">
|
||||
<div className="pop-modal-header">
|
||||
<h2 className="pop-modal-title">작업 접수</h2>
|
||||
<button className="pop-modal-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-body">
|
||||
<div className="pop-accept-modal-content">
|
||||
{/* 작업지시 정보 */}
|
||||
<div className="pop-accept-work-info">
|
||||
<div className="work-id">{workOrder.id}</div>
|
||||
<div className="work-name">
|
||||
{workOrder.itemName} ({workOrder.spec})
|
||||
</div>
|
||||
<div style={{ marginTop: "var(--spacing-sm)", fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
|
||||
지시수량: {workOrder.orderQuantity} EA | 기 접수: {acceptedQty} EA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수량 입력 */}
|
||||
<div>
|
||||
<label className="pop-form-label">접수 수량</label>
|
||||
<div className="pop-quantity-input-wrapper">
|
||||
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-10)}>
|
||||
-10
|
||||
</button>
|
||||
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-1)}>
|
||||
-1
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
className="pop-qty-input"
|
||||
value={quantity}
|
||||
onChange={handleInputChange}
|
||||
min={1}
|
||||
max={remainingQty}
|
||||
/>
|
||||
<button className="pop-qty-btn" onClick={() => handleAdjust(1)}>
|
||||
+1
|
||||
</button>
|
||||
<button className="pop-qty-btn" onClick={() => handleAdjust(10)}>
|
||||
+10
|
||||
</button>
|
||||
</div>
|
||||
<div className="pop-qty-hint">미접수 수량: {remainingQty} EA</div>
|
||||
</div>
|
||||
|
||||
{/* 분할접수 안내 */}
|
||||
{quantity < remainingQty && (
|
||||
<div className="pop-accept-info-box">
|
||||
<span className="info-icon">
|
||||
<Info size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="info-title">분할 접수</div>
|
||||
<div className="info-desc">
|
||||
{quantity}EA 접수 후 {remainingQty - quantity}EA가 접수대기 상태로 남습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-footer">
|
||||
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="pop-btn pop-btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleConfirm}
|
||||
disabled={quantity <= 0}
|
||||
>
|
||||
접수 ({quantity} EA)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,462 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import "./styles.css";
|
||||
|
||||
import {
|
||||
AppState,
|
||||
ModalState,
|
||||
PanelState,
|
||||
StatusType,
|
||||
ProductionType,
|
||||
WorkOrder,
|
||||
WorkStep,
|
||||
Equipment,
|
||||
Process,
|
||||
} from "./types";
|
||||
import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data";
|
||||
|
||||
import { PopHeader } from "./PopHeader";
|
||||
import { PopStatusTabs } from "./PopStatusTabs";
|
||||
import { PopWorkCard } from "./PopWorkCard";
|
||||
import { PopBottomNav } from "./PopBottomNav";
|
||||
import { PopEquipmentModal } from "./PopEquipmentModal";
|
||||
import { PopProcessModal } from "./PopProcessModal";
|
||||
import { PopAcceptModal } from "./PopAcceptModal";
|
||||
import { PopSettingsModal } from "./PopSettingsModal";
|
||||
import { PopProductionPanel } from "./PopProductionPanel";
|
||||
|
||||
export function PopApp() {
|
||||
// 앱 상태
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
currentStatus: "waiting",
|
||||
selectedEquipment: null,
|
||||
selectedProcess: null,
|
||||
selectedWorkOrder: null,
|
||||
showMyWorkOnly: false,
|
||||
currentWorkSteps: [],
|
||||
currentStepIndex: 0,
|
||||
currentProductionType: "work-order",
|
||||
selectionMode: "single",
|
||||
completionAction: "close",
|
||||
acceptTargetWorkOrder: null,
|
||||
acceptQuantity: 0,
|
||||
theme: "dark",
|
||||
});
|
||||
|
||||
// 모달 상태
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
equipment: false,
|
||||
process: false,
|
||||
accept: false,
|
||||
settings: false,
|
||||
});
|
||||
|
||||
// 패널 상태
|
||||
const [panelState, setPanelState] = useState<PanelState>({
|
||||
production: false,
|
||||
});
|
||||
|
||||
// 현재 시간 (hydration 에러 방지를 위해 초기값 null)
|
||||
const [currentDateTime, setCurrentDateTime] = useState<Date | null>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리)
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>(WORK_ORDERS);
|
||||
|
||||
// 클라이언트 마운트 확인 및 시계 업데이트
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
setCurrentDateTime(new Date());
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCurrentDateTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 로컬 스토리지에서 설정 로드
|
||||
useEffect(() => {
|
||||
const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null;
|
||||
const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null;
|
||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
selectionMode: savedSelectionMode || "single",
|
||||
completionAction: savedCompletionAction || "close",
|
||||
theme: savedTheme || "dark",
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 상태별 카운트 계산
|
||||
const getStatusCounts = useCallback(() => {
|
||||
const myProcessId = appState.selectedProcess?.id;
|
||||
|
||||
let waitingCount = 0;
|
||||
let pendingAcceptCount = 0;
|
||||
let inProgressCount = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
workOrders.forEach((wo) => {
|
||||
if (!wo.processFlow) return;
|
||||
|
||||
const myProcessIndex = myProcessId
|
||||
? wo.processFlow.findIndex((step) => step.id === myProcessId)
|
||||
: -1;
|
||||
|
||||
if (wo.status === "completed") {
|
||||
completedCount++;
|
||||
} else if (wo.status === "in-progress" && wo.accepted) {
|
||||
inProgressCount++;
|
||||
} else if (myProcessIndex >= 0) {
|
||||
const currentProcessIndex = wo.currentProcessIndex || 0;
|
||||
const myStep = wo.processFlow[myProcessIndex];
|
||||
|
||||
if (currentProcessIndex < myProcessIndex) {
|
||||
waitingCount++;
|
||||
} else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") {
|
||||
pendingAcceptCount++;
|
||||
} else if (myStep.status === "completed") {
|
||||
completedCount++;
|
||||
}
|
||||
} else {
|
||||
if (wo.status === "waiting") waitingCount++;
|
||||
else if (wo.status === "in-progress") inProgressCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return { waitingCount, pendingAcceptCount, inProgressCount, completedCount };
|
||||
}, [workOrders, appState.selectedProcess]);
|
||||
|
||||
// 필터링된 작업 목록
|
||||
const getFilteredWorkOrders = useCallback(() => {
|
||||
const myProcessId = appState.selectedProcess?.id;
|
||||
let filtered: WorkOrder[] = [];
|
||||
|
||||
workOrders.forEach((wo) => {
|
||||
if (!wo.processFlow) return;
|
||||
|
||||
const myProcessIndex = myProcessId
|
||||
? wo.processFlow.findIndex((step) => step.id === myProcessId)
|
||||
: -1;
|
||||
const currentProcessIndex = wo.currentProcessIndex || 0;
|
||||
const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null;
|
||||
|
||||
switch (appState.currentStatus) {
|
||||
case "waiting":
|
||||
if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) {
|
||||
filtered.push(wo);
|
||||
} else if (!myProcessId && wo.status === "waiting") {
|
||||
filtered.push(wo);
|
||||
}
|
||||
break;
|
||||
|
||||
case "pending-accept":
|
||||
if (
|
||||
myProcessIndex >= 0 &&
|
||||
currentProcessIndex === myProcessIndex &&
|
||||
myStep &&
|
||||
myStep.status !== "completed" &&
|
||||
!wo.accepted
|
||||
) {
|
||||
filtered.push(wo);
|
||||
}
|
||||
break;
|
||||
|
||||
case "in-progress":
|
||||
if (wo.accepted && wo.status === "in-progress") {
|
||||
filtered.push(wo);
|
||||
} else if (!myProcessId && wo.status === "in-progress") {
|
||||
filtered.push(wo);
|
||||
}
|
||||
break;
|
||||
|
||||
case "completed":
|
||||
if (wo.status === "completed") {
|
||||
filtered.push(wo);
|
||||
} else if (myStep && myStep.status === "completed") {
|
||||
filtered.push(wo);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 내 작업만 보기 필터
|
||||
if (appState.showMyWorkOnly && myProcessId) {
|
||||
filtered = filtered.filter((wo) => {
|
||||
const mySteps = wo.processFlow.filter((step) => step.id === myProcessId);
|
||||
if (mySteps.length === 0) return false;
|
||||
return !mySteps.every((step) => step.status === "completed");
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]);
|
||||
|
||||
// 상태 탭 변경
|
||||
const handleStatusChange = (status: StatusType) => {
|
||||
setAppState((prev) => ({ ...prev, currentStatus: status }));
|
||||
};
|
||||
|
||||
// 생산 유형 변경
|
||||
const handleProductionTypeChange = (type: ProductionType) => {
|
||||
setAppState((prev) => ({ ...prev, currentProductionType: type }));
|
||||
};
|
||||
|
||||
// 내 작업만 보기 토글
|
||||
const handleMyWorkToggle = () => {
|
||||
setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly }));
|
||||
};
|
||||
|
||||
// 테마 토글
|
||||
const handleThemeToggle = () => {
|
||||
const newTheme = appState.theme === "dark" ? "light" : "dark";
|
||||
setAppState((prev) => ({ ...prev, theme: newTheme }));
|
||||
localStorage.setItem("popTheme", newTheme);
|
||||
};
|
||||
|
||||
// 모달 열기/닫기
|
||||
const openModal = (type: keyof ModalState) => {
|
||||
setModalState((prev) => ({ ...prev, [type]: true }));
|
||||
};
|
||||
|
||||
const closeModal = (type: keyof ModalState) => {
|
||||
setModalState((prev) => ({ ...prev, [type]: false }));
|
||||
};
|
||||
|
||||
// 설비 선택
|
||||
const handleEquipmentSelect = (equipment: Equipment) => {
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
selectedEquipment: equipment,
|
||||
// 공정이 1개면 자동 선택
|
||||
selectedProcess:
|
||||
equipment.processIds.length === 1
|
||||
? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null
|
||||
: null,
|
||||
}));
|
||||
};
|
||||
|
||||
// 공정 선택
|
||||
const handleProcessSelect = (process: Process) => {
|
||||
setAppState((prev) => ({ ...prev, selectedProcess: process }));
|
||||
};
|
||||
|
||||
// 작업 접수 모달 열기
|
||||
const handleOpenAcceptModal = (workOrder: WorkOrder) => {
|
||||
const acceptedQty = workOrder.acceptedQuantity || 0;
|
||||
const remainingQty = workOrder.orderQuantity - acceptedQty;
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
acceptTargetWorkOrder: workOrder,
|
||||
acceptQuantity: remainingQty,
|
||||
}));
|
||||
openModal("accept");
|
||||
};
|
||||
|
||||
// 접수 확인
|
||||
const handleConfirmAccept = (quantity: number) => {
|
||||
if (!appState.acceptTargetWorkOrder) return;
|
||||
|
||||
setWorkOrders((prev) =>
|
||||
prev.map((wo) => {
|
||||
if (wo.id === appState.acceptTargetWorkOrder!.id) {
|
||||
const previousAccepted = wo.acceptedQuantity || 0;
|
||||
const newAccepted = previousAccepted + quantity;
|
||||
return {
|
||||
...wo,
|
||||
acceptedQuantity: newAccepted,
|
||||
remainingQuantity: wo.orderQuantity - newAccepted,
|
||||
accepted: true,
|
||||
status: "in-progress" as const,
|
||||
isPartialAccept: newAccepted < wo.orderQuantity,
|
||||
};
|
||||
}
|
||||
return wo;
|
||||
})
|
||||
);
|
||||
|
||||
closeModal("accept");
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
acceptTargetWorkOrder: null,
|
||||
acceptQuantity: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
// 접수 취소
|
||||
const handleCancelAccept = (workOrderId: string) => {
|
||||
setWorkOrders((prev) =>
|
||||
prev.map((wo) => {
|
||||
if (wo.id === workOrderId) {
|
||||
return {
|
||||
...wo,
|
||||
accepted: false,
|
||||
acceptedQuantity: 0,
|
||||
remainingQuantity: wo.orderQuantity,
|
||||
isPartialAccept: false,
|
||||
status: "waiting" as const,
|
||||
};
|
||||
}
|
||||
return wo;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// 생산진행 패널 열기
|
||||
const handleOpenProductionPanel = (workOrder: WorkOrder) => {
|
||||
const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"];
|
||||
const workSteps: WorkStep[] = template.map((step) => ({
|
||||
...step,
|
||||
status: "pending" as const,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
data: {},
|
||||
}));
|
||||
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
selectedWorkOrder: workOrder,
|
||||
currentWorkSteps: workSteps,
|
||||
currentStepIndex: 0,
|
||||
}));
|
||||
setPanelState((prev) => ({ ...prev, production: true }));
|
||||
};
|
||||
|
||||
// 생산진행 패널 닫기
|
||||
const handleCloseProductionPanel = () => {
|
||||
setPanelState((prev) => ({ ...prev, production: false }));
|
||||
setAppState((prev) => ({
|
||||
...prev,
|
||||
selectedWorkOrder: null,
|
||||
currentWorkSteps: [],
|
||||
currentStepIndex: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
// 설정 저장
|
||||
const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => {
|
||||
setAppState((prev) => ({ ...prev, selectionMode, completionAction }));
|
||||
localStorage.setItem("selectionMode", selectionMode);
|
||||
localStorage.setItem("completionAction", completionAction);
|
||||
closeModal("settings");
|
||||
};
|
||||
|
||||
const statusCounts = getStatusCounts();
|
||||
const filteredWorkOrders = getFilteredWorkOrders();
|
||||
|
||||
return (
|
||||
<div className={`pop-container ${appState.theme === "light" ? "light" : ""}`}>
|
||||
<div className="pop-app">
|
||||
{/* 헤더 */}
|
||||
<PopHeader
|
||||
currentDateTime={currentDateTime || new Date()}
|
||||
productionType={appState.currentProductionType}
|
||||
selectedEquipment={appState.selectedEquipment}
|
||||
selectedProcess={appState.selectedProcess}
|
||||
showMyWorkOnly={appState.showMyWorkOnly}
|
||||
theme={appState.theme}
|
||||
onProductionTypeChange={handleProductionTypeChange}
|
||||
onEquipmentClick={() => openModal("equipment")}
|
||||
onProcessClick={() => openModal("process")}
|
||||
onMyWorkToggle={handleMyWorkToggle}
|
||||
onSearchClick={() => {
|
||||
/* 조회 */
|
||||
}}
|
||||
onSettingsClick={() => openModal("settings")}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
/>
|
||||
|
||||
{/* 상태 탭 */}
|
||||
<PopStatusTabs
|
||||
currentStatus={appState.currentStatus}
|
||||
counts={statusCounts}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="pop-main-content">
|
||||
{filteredWorkOrders.length === 0 ? (
|
||||
<div className="pop-empty-state">
|
||||
<div className="pop-empty-state-text">작업이 없습니다</div>
|
||||
<div className="pop-empty-state-desc">
|
||||
{appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"}
|
||||
{appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"}
|
||||
{appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"}
|
||||
{appState.currentStatus === "completed" && "완료된 작업이 없습니다"}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pop-work-list">
|
||||
{filteredWorkOrders.map((workOrder) => (
|
||||
<PopWorkCard
|
||||
key={workOrder.id}
|
||||
workOrder={workOrder}
|
||||
currentStatus={appState.currentStatus}
|
||||
selectedProcess={appState.selectedProcess}
|
||||
onAccept={() => handleOpenAcceptModal(workOrder)}
|
||||
onCancelAccept={() => handleCancelAccept(workOrder.id)}
|
||||
onStartProduction={() => handleOpenProductionPanel(workOrder)}
|
||||
onClick={() => handleOpenProductionPanel(workOrder)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<PopBottomNav />
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<PopEquipmentModal
|
||||
isOpen={modalState.equipment}
|
||||
equipments={EQUIPMENTS}
|
||||
selectedEquipment={appState.selectedEquipment}
|
||||
onSelect={handleEquipmentSelect}
|
||||
onClose={() => closeModal("equipment")}
|
||||
/>
|
||||
|
||||
<PopProcessModal
|
||||
isOpen={modalState.process}
|
||||
selectedEquipment={appState.selectedEquipment}
|
||||
selectedProcess={appState.selectedProcess}
|
||||
processes={PROCESSES}
|
||||
onSelect={handleProcessSelect}
|
||||
onClose={() => closeModal("process")}
|
||||
/>
|
||||
|
||||
<PopAcceptModal
|
||||
isOpen={modalState.accept}
|
||||
workOrder={appState.acceptTargetWorkOrder}
|
||||
quantity={appState.acceptQuantity}
|
||||
onQuantityChange={(qty) => setAppState((prev) => ({ ...prev, acceptQuantity: qty }))}
|
||||
onConfirm={handleConfirmAccept}
|
||||
onClose={() => closeModal("accept")}
|
||||
/>
|
||||
|
||||
<PopSettingsModal
|
||||
isOpen={modalState.settings}
|
||||
selectionMode={appState.selectionMode}
|
||||
completionAction={appState.completionAction}
|
||||
onSave={handleSaveSettings}
|
||||
onClose={() => closeModal("settings")}
|
||||
/>
|
||||
|
||||
{/* 생산진행 패널 */}
|
||||
<PopProductionPanel
|
||||
isOpen={panelState.production}
|
||||
workOrder={appState.selectedWorkOrder}
|
||||
workSteps={appState.currentWorkSteps}
|
||||
currentStepIndex={appState.currentStepIndex}
|
||||
currentDateTime={currentDateTime || new Date()}
|
||||
onStepChange={(index) => setAppState((prev) => ({ ...prev, currentStepIndex: index }))}
|
||||
onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))}
|
||||
onClose={handleCloseProductionPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Clock, ClipboardList } from "lucide-react";
|
||||
|
||||
export function PopBottomNav() {
|
||||
const handleHistoryClick = () => {
|
||||
console.log("작업이력 클릭");
|
||||
// TODO: 작업이력 페이지 이동 또는 모달 열기
|
||||
};
|
||||
|
||||
const handleRegisterClick = () => {
|
||||
console.log("실적등록 클릭");
|
||||
// TODO: 실적등록 모달 열기
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-bottom-nav">
|
||||
<button className="pop-nav-btn secondary" onClick={handleHistoryClick}>
|
||||
<Clock size={18} />
|
||||
작업이력
|
||||
</button>
|
||||
<button className="pop-nav-btn primary" onClick={handleRegisterClick}>
|
||||
<ClipboardList size={18} />
|
||||
실적등록
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Equipment } from "./types";
|
||||
|
||||
interface PopEquipmentModalProps {
|
||||
isOpen: boolean;
|
||||
equipments: Equipment[];
|
||||
selectedEquipment: Equipment | null;
|
||||
onSelect: (equipment: Equipment) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PopEquipmentModal({
|
||||
isOpen,
|
||||
equipments,
|
||||
selectedEquipment,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: PopEquipmentModalProps) {
|
||||
const [tempSelected, setTempSelected] = React.useState<Equipment | null>(selectedEquipment);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTempSelected(selectedEquipment);
|
||||
}, [selectedEquipment, isOpen]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (tempSelected) {
|
||||
onSelect(tempSelected);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="pop-modal">
|
||||
<div className="pop-modal-header">
|
||||
<h2 className="pop-modal-title">설비 선택</h2>
|
||||
<button className="pop-modal-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-body">
|
||||
<div className="pop-selection-grid">
|
||||
{equipments.map((equip) => (
|
||||
<div
|
||||
key={equip.id}
|
||||
className={`pop-selection-card ${tempSelected?.id === equip.id ? "selected" : ""}`}
|
||||
onClick={() => setTempSelected(equip)}
|
||||
>
|
||||
<div className="pop-selection-card-check">✓</div>
|
||||
<div className="pop-selection-card-name">{equip.name}</div>
|
||||
<div className="pop-selection-card-info">{equip.processNames.join(", ")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-footer">
|
||||
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="pop-btn pop-btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleConfirm}
|
||||
disabled={!tempSelected}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Equipment, Process, ProductionType } from "./types";
|
||||
|
||||
interface PopHeaderProps {
|
||||
currentDateTime: Date;
|
||||
productionType: ProductionType;
|
||||
selectedEquipment: Equipment | null;
|
||||
selectedProcess: Process | null;
|
||||
showMyWorkOnly: boolean;
|
||||
theme: "dark" | "light";
|
||||
onProductionTypeChange: (type: ProductionType) => void;
|
||||
onEquipmentClick: () => void;
|
||||
onProcessClick: () => void;
|
||||
onMyWorkToggle: () => void;
|
||||
onSearchClick: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onThemeToggle: () => void;
|
||||
}
|
||||
|
||||
export function PopHeader({
|
||||
currentDateTime,
|
||||
productionType,
|
||||
selectedEquipment,
|
||||
selectedProcess,
|
||||
showMyWorkOnly,
|
||||
theme,
|
||||
onProductionTypeChange,
|
||||
onEquipmentClick,
|
||||
onProcessClick,
|
||||
onMyWorkToggle,
|
||||
onSearchClick,
|
||||
onSettingsClick,
|
||||
onThemeToggle,
|
||||
}: PopHeaderProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-header-container">
|
||||
{/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */}
|
||||
<div className="pop-top-bar row-1">
|
||||
<div className="pop-datetime">
|
||||
<span className="pop-date">{mounted ? formatDate(currentDateTime) : "----.--.--"}</span>
|
||||
<span className="pop-time">{mounted ? formatTime(currentDateTime) : "--:--"}</span>
|
||||
</div>
|
||||
|
||||
{/* 테마 토글 버튼 */}
|
||||
<button className="pop-theme-toggle-inline" onClick={onThemeToggle} title="테마 변경">
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
|
||||
<div className="pop-spacer" />
|
||||
|
||||
<div className="pop-type-buttons">
|
||||
<button
|
||||
className={`pop-type-btn ${productionType === "work-order" ? "active" : ""}`}
|
||||
onClick={() => onProductionTypeChange("work-order")}
|
||||
>
|
||||
작업지시
|
||||
</button>
|
||||
<button
|
||||
className={`pop-type-btn ${productionType === "material" ? "active" : ""}`}
|
||||
onClick={() => onProductionTypeChange("material")}
|
||||
>
|
||||
원자재
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2행: 필터 버튼들 */}
|
||||
<div className="pop-top-bar row-2">
|
||||
<button
|
||||
className={`pop-filter-btn ${selectedEquipment ? "active" : ""}`}
|
||||
onClick={onEquipmentClick}
|
||||
>
|
||||
<span>{selectedEquipment?.name || "설비"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`pop-filter-btn ${selectedProcess ? "active" : ""}`}
|
||||
onClick={onProcessClick}
|
||||
disabled={!selectedEquipment}
|
||||
>
|
||||
<span>{selectedProcess?.name || "공정"}</span>
|
||||
</button>
|
||||
<button
|
||||
className={`pop-filter-btn ${showMyWorkOnly ? "active" : ""}`}
|
||||
onClick={onMyWorkToggle}
|
||||
>
|
||||
내 작업
|
||||
</button>
|
||||
|
||||
<div className="pop-spacer" />
|
||||
|
||||
<button className="pop-filter-btn primary" onClick={onSearchClick}>
|
||||
조회
|
||||
</button>
|
||||
<button className="pop-filter-btn" onClick={onSettingsClick}>
|
||||
설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Equipment, Process } from "./types";
|
||||
|
||||
interface PopProcessModalProps {
|
||||
isOpen: boolean;
|
||||
selectedEquipment: Equipment | null;
|
||||
selectedProcess: Process | null;
|
||||
processes: Process[];
|
||||
onSelect: (process: Process) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PopProcessModal({
|
||||
isOpen,
|
||||
selectedEquipment,
|
||||
selectedProcess,
|
||||
processes,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: PopProcessModalProps) {
|
||||
const [tempSelected, setTempSelected] = React.useState<Process | null>(selectedProcess);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTempSelected(selectedProcess);
|
||||
}, [selectedProcess, isOpen]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (tempSelected) {
|
||||
onSelect(tempSelected);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !selectedEquipment) return null;
|
||||
|
||||
// 선택된 설비의 공정만 필터링
|
||||
const availableProcesses = selectedEquipment.processIds.map((processId, index) => {
|
||||
const process = processes.find((p) => p.id === processId);
|
||||
return {
|
||||
id: processId,
|
||||
name: selectedEquipment.processNames[index],
|
||||
code: process?.code || "",
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="pop-modal">
|
||||
<div className="pop-modal-header">
|
||||
<h2 className="pop-modal-title">공정 선택</h2>
|
||||
<button className="pop-modal-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-body">
|
||||
<div className="pop-selection-grid">
|
||||
{availableProcesses.map((process) => (
|
||||
<div
|
||||
key={process.id}
|
||||
className={`pop-selection-card ${tempSelected?.id === process.id ? "selected" : ""}`}
|
||||
onClick={() => setTempSelected(process as Process)}
|
||||
>
|
||||
<div className="pop-selection-card-check">✓</div>
|
||||
<div className="pop-selection-card-name">{process.name}</div>
|
||||
<div className="pop-selection-card-info">{process.code}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-footer">
|
||||
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
className="pop-btn pop-btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleConfirm}
|
||||
disabled={!tempSelected}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { X, Play, Square, ChevronRight } from "lucide-react";
|
||||
import { WorkOrder, WorkStep } from "./types";
|
||||
|
||||
interface PopProductionPanelProps {
|
||||
isOpen: boolean;
|
||||
workOrder: WorkOrder | null;
|
||||
workSteps: WorkStep[];
|
||||
currentStepIndex: number;
|
||||
currentDateTime: Date;
|
||||
onStepChange: (index: number) => void;
|
||||
onStepsUpdate: (steps: WorkStep[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PopProductionPanel({
|
||||
isOpen,
|
||||
workOrder,
|
||||
workSteps,
|
||||
currentStepIndex,
|
||||
currentDateTime,
|
||||
onStepChange,
|
||||
onStepsUpdate,
|
||||
onClose,
|
||||
}: PopProductionPanelProps) {
|
||||
if (!isOpen || !workOrder) return null;
|
||||
|
||||
const currentStep = workSteps[currentStepIndex];
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatTime = (date: Date | null) => {
|
||||
if (!date) return "--:--";
|
||||
const d = new Date(date);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleStartStep = () => {
|
||||
const newSteps = [...workSteps];
|
||||
newSteps[currentStepIndex] = {
|
||||
...newSteps[currentStepIndex],
|
||||
status: "in-progress",
|
||||
startTime: new Date(),
|
||||
};
|
||||
onStepsUpdate(newSteps);
|
||||
};
|
||||
|
||||
const handleEndStep = () => {
|
||||
const newSteps = [...workSteps];
|
||||
newSteps[currentStepIndex] = {
|
||||
...newSteps[currentStepIndex],
|
||||
endTime: new Date(),
|
||||
};
|
||||
onStepsUpdate(newSteps);
|
||||
};
|
||||
|
||||
const handleSaveAndNext = () => {
|
||||
const newSteps = [...workSteps];
|
||||
const step = newSteps[currentStepIndex];
|
||||
|
||||
// 시간 자동 설정
|
||||
if (!step.startTime) step.startTime = new Date();
|
||||
if (!step.endTime) step.endTime = new Date();
|
||||
step.status = "completed";
|
||||
|
||||
onStepsUpdate(newSteps);
|
||||
|
||||
// 다음 단계로 이동
|
||||
if (currentStepIndex < workSteps.length - 1) {
|
||||
onStepChange(currentStepIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepForm = () => {
|
||||
if (!currentStep) return null;
|
||||
|
||||
const isCompleted = currentStep.status === "completed";
|
||||
|
||||
if (currentStep.type === "work" || currentStep.type === "record") {
|
||||
return (
|
||||
<div className="pop-step-form-section">
|
||||
<h4 className="pop-step-form-title">작업 내용 입력</h4>
|
||||
<div className="pop-form-row">
|
||||
<div className="pop-form-group">
|
||||
<label className="pop-form-label">생산수량</label>
|
||||
<input
|
||||
type="number"
|
||||
className="pop-input"
|
||||
placeholder="0"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
<div className="pop-form-group">
|
||||
<label className="pop-form-label">불량수량</label>
|
||||
<input
|
||||
type="number"
|
||||
className="pop-input"
|
||||
placeholder="0"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pop-form-group">
|
||||
<label className="pop-form-label">비고</label>
|
||||
<textarea
|
||||
className="pop-input"
|
||||
rows={2}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentStep.type === "equipment-check" || currentStep.type === "inspection") {
|
||||
return (
|
||||
<div className="pop-step-form-section">
|
||||
<h4 className="pop-step-form-title">점검 항목</h4>
|
||||
<div className="pop-checkbox-list">
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>장비 상태 확인</span>
|
||||
</label>
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>안전 장비 착용</span>
|
||||
</label>
|
||||
<label className="pop-checkbox-label">
|
||||
<input type="checkbox" className="pop-checkbox" disabled={isCompleted} />
|
||||
<span>작업 환경 확인</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="pop-form-group" style={{ marginTop: "var(--spacing-md)" }}>
|
||||
<label className="pop-form-label">비고</label>
|
||||
<textarea
|
||||
className="pop-input"
|
||||
rows={2}
|
||||
placeholder="점검 결과를 입력하세요"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pop-step-form-section">
|
||||
<h4 className="pop-step-form-title">작업 메모</h4>
|
||||
<div className="pop-form-group">
|
||||
<textarea
|
||||
className="pop-input"
|
||||
rows={3}
|
||||
placeholder="메모를 입력하세요"
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-slide-panel active">
|
||||
<div className="pop-slide-panel-overlay" onClick={onClose} />
|
||||
<div className="pop-slide-panel-content">
|
||||
{/* 헤더 */}
|
||||
<div className="pop-slide-panel-header">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
|
||||
<h2 className="pop-slide-panel-title">생산진행</h2>
|
||||
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
|
||||
<div className="pop-panel-datetime">
|
||||
<span className="pop-panel-date">{formatDate(currentDateTime)}</span>
|
||||
<span className="pop-panel-time">{formatTime(currentDateTime)}</span>
|
||||
</div>
|
||||
<button className="pop-icon-btn" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업지시 정보 */}
|
||||
<div className="pop-work-order-info-section">
|
||||
<div className="pop-work-order-info-card">
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">작업지시</span>
|
||||
<span className="value primary">{workOrder.id}</span>
|
||||
</div>
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">품목</span>
|
||||
<span className="value">{workOrder.itemName}</span>
|
||||
</div>
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">규격</span>
|
||||
<span className="value">{workOrder.spec}</span>
|
||||
</div>
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">지시수량</span>
|
||||
<span className="value">{workOrder.orderQuantity} EA</span>
|
||||
</div>
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">생산수량</span>
|
||||
<span className="value">{workOrder.producedQuantity} EA</span>
|
||||
</div>
|
||||
<div className="pop-work-order-info-item">
|
||||
<span className="label">납기일</span>
|
||||
<span className="value">{workOrder.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바디 */}
|
||||
<div className="pop-slide-panel-body">
|
||||
<div className="pop-panel-body-content">
|
||||
{/* 작업순서 사이드바 */}
|
||||
<div className="pop-work-steps-sidebar">
|
||||
<div className="pop-work-steps-header">작업순서</div>
|
||||
<div className="pop-work-steps-list">
|
||||
{workSteps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`pop-work-step-item ${index === currentStepIndex ? "active" : ""} ${step.status}`}
|
||||
onClick={() => onStepChange(index)}
|
||||
>
|
||||
<div className="pop-work-step-number">{index + 1}</div>
|
||||
<div className="pop-work-step-info">
|
||||
<div className="pop-work-step-name">{step.name}</div>
|
||||
<div className="pop-work-step-time">
|
||||
{formatTime(step.startTime)} ~ {formatTime(step.endTime)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`pop-work-step-status ${step.status}`}>
|
||||
{step.status === "completed" ? "완료" : step.status === "in-progress" ? "진행중" : "대기"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업 콘텐츠 영역 */}
|
||||
<div className="pop-work-content-area">
|
||||
{currentStep && (
|
||||
<>
|
||||
{/* 스텝 헤더 */}
|
||||
<div className="pop-step-header">
|
||||
<h3 className="pop-step-title">{currentStep.name}</h3>
|
||||
<p className="pop-step-description">{currentStep.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 시간 컨트롤 */}
|
||||
{currentStep.status !== "completed" && (
|
||||
<div className="pop-step-time-controls">
|
||||
<button
|
||||
className="pop-time-control-btn start"
|
||||
onClick={handleStartStep}
|
||||
disabled={!!currentStep.startTime}
|
||||
>
|
||||
<Play size={16} />
|
||||
시작 {currentStep.startTime ? formatTime(currentStep.startTime) : ""}
|
||||
</button>
|
||||
<button
|
||||
className="pop-time-control-btn end"
|
||||
onClick={handleEndStep}
|
||||
disabled={!currentStep.startTime || !!currentStep.endTime}
|
||||
>
|
||||
<Square size={16} />
|
||||
종료 {currentStep.endTime ? formatTime(currentStep.endTime) : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 폼 */}
|
||||
{renderStepForm()}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{currentStep.status !== "completed" && (
|
||||
<div style={{ marginTop: "auto", display: "flex", gap: "var(--spacing-md)" }}>
|
||||
<button
|
||||
className="pop-btn pop-btn-outline"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => onStepChange(Math.max(0, currentStepIndex - 1))}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
className="pop-btn pop-btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleSaveAndNext}
|
||||
>
|
||||
{currentStepIndex === workSteps.length - 1 ? "완료" : "저장 후 다음"}
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 완료 메시지 */}
|
||||
{currentStep.status === "completed" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "var(--spacing-md)",
|
||||
background: "rgba(0, 255, 136, 0.1)",
|
||||
border: "1px solid rgba(0, 255, 136, 0.3)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--spacing-sm)",
|
||||
color: "rgb(var(--success))",
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span style={{ fontWeight: 600 }}>작업이 완료되었습니다</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="pop-slide-panel-footer">
|
||||
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }}>
|
||||
작업 완료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface PopSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
selectionMode: "single" | "multi";
|
||||
completionAction: "close" | "stay";
|
||||
onSave: (selectionMode: "single" | "multi", completionAction: "close" | "stay") => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PopSettingsModal({
|
||||
isOpen,
|
||||
selectionMode,
|
||||
completionAction,
|
||||
onSave,
|
||||
onClose,
|
||||
}: PopSettingsModalProps) {
|
||||
const [tempSelectionMode, setTempSelectionMode] = useState(selectionMode);
|
||||
const [tempCompletionAction, setTempCompletionAction] = useState(completionAction);
|
||||
|
||||
useEffect(() => {
|
||||
setTempSelectionMode(selectionMode);
|
||||
setTempCompletionAction(completionAction);
|
||||
}, [selectionMode, completionAction, isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(tempSelectionMode, tempCompletionAction);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="pop-modal">
|
||||
<div className="pop-modal-header">
|
||||
<h2 className="pop-modal-title">설정</h2>
|
||||
<button className="pop-modal-close" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-body">
|
||||
{/* 선택 모드 */}
|
||||
<div className="pop-settings-section">
|
||||
<h3 className="pop-settings-title">설비/공정 선택 모드</h3>
|
||||
<div className="pop-mode-options">
|
||||
<label className="pop-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="selectionMode"
|
||||
value="single"
|
||||
checked={tempSelectionMode === "single"}
|
||||
onChange={() => setTempSelectionMode("single")}
|
||||
/>
|
||||
<div className="pop-mode-info">
|
||||
<div className="pop-mode-name">단일 선택 모드</div>
|
||||
<div className="pop-mode-desc">
|
||||
설비와 공정을 선택하여 해당 작업만 표시합니다.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="pop-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="selectionMode"
|
||||
value="multi"
|
||||
checked={tempSelectionMode === "multi"}
|
||||
onChange={() => setTempSelectionMode("multi")}
|
||||
/>
|
||||
<div className="pop-mode-info">
|
||||
<div className="pop-mode-name">다중 선택 모드</div>
|
||||
<div className="pop-mode-desc">
|
||||
모든 설비/공정의 작업을 표시합니다.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-settings-divider" />
|
||||
|
||||
{/* 완료 후 동작 */}
|
||||
<div className="pop-settings-section">
|
||||
<h3 className="pop-settings-title">작업 완료 후 동작</h3>
|
||||
<div className="pop-mode-options">
|
||||
<label className="pop-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="completionAction"
|
||||
value="close"
|
||||
checked={tempCompletionAction === "close"}
|
||||
onChange={() => setTempCompletionAction("close")}
|
||||
/>
|
||||
<div className="pop-mode-info">
|
||||
<div className="pop-mode-name">패널 닫기</div>
|
||||
<div className="pop-mode-desc">
|
||||
작업 완료 시 생산진행 패널을 자동으로 닫습니다.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="pop-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="completionAction"
|
||||
value="stay"
|
||||
checked={tempCompletionAction === "stay"}
|
||||
onChange={() => setTempCompletionAction("stay")}
|
||||
/>
|
||||
<div className="pop-mode-info">
|
||||
<div className="pop-mode-name">패널 유지</div>
|
||||
<div className="pop-mode-desc">
|
||||
작업 완료 후에도 패널을 유지합니다.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-modal-footer">
|
||||
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }} onClick={handleSave}>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { StatusType } from "./types";
|
||||
|
||||
interface StatusCounts {
|
||||
waitingCount: number;
|
||||
pendingAcceptCount: number;
|
||||
inProgressCount: number;
|
||||
completedCount: number;
|
||||
}
|
||||
|
||||
interface PopStatusTabsProps {
|
||||
currentStatus: StatusType;
|
||||
counts: StatusCounts;
|
||||
onStatusChange: (status: StatusType) => void;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: {
|
||||
id: StatusType;
|
||||
label: string;
|
||||
detail: string;
|
||||
countKey: keyof StatusCounts;
|
||||
}[] = [
|
||||
{ id: "waiting", label: "대기", detail: "내 공정 이전", countKey: "waitingCount" },
|
||||
{ id: "pending-accept", label: "접수대기", detail: "내 차례", countKey: "pendingAcceptCount" },
|
||||
{ id: "in-progress", label: "진행", detail: "작업중", countKey: "inProgressCount" },
|
||||
{ id: "completed", label: "완료", detail: "처리완료", countKey: "completedCount" },
|
||||
];
|
||||
|
||||
export function PopStatusTabs({ currentStatus, counts, onStatusChange }: PopStatusTabsProps) {
|
||||
return (
|
||||
<div className="pop-status-tabs">
|
||||
{STATUS_CONFIG.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
className={`pop-status-tab ${currentStatus === status.id ? "active" : ""}`}
|
||||
onClick={() => onStatusChange(status.id)}
|
||||
>
|
||||
<span className="pop-status-tab-label">{status.label}</span>
|
||||
<span className="pop-status-tab-count">{counts[status.countKey]}</span>
|
||||
<span className="pop-status-tab-detail">{status.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { WorkOrder, Process, StatusType } from "./types";
|
||||
import { STATUS_TEXT } from "./data";
|
||||
|
||||
interface PopWorkCardProps {
|
||||
workOrder: WorkOrder;
|
||||
currentStatus: StatusType;
|
||||
selectedProcess: Process | null;
|
||||
onAccept: () => void;
|
||||
onCancelAccept: () => void;
|
||||
onStartProduction: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function PopWorkCard({
|
||||
workOrder,
|
||||
currentStatus,
|
||||
selectedProcess,
|
||||
onAccept,
|
||||
onCancelAccept,
|
||||
onStartProduction,
|
||||
onClick,
|
||||
}: PopWorkCardProps) {
|
||||
const chipsRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftBtn, setShowLeftBtn] = useState(false);
|
||||
const [showRightBtn, setShowRightBtn] = useState(false);
|
||||
|
||||
const progress = ((workOrder.producedQuantity / workOrder.orderQuantity) * 100).toFixed(1);
|
||||
const isReturnWork = workOrder.isReturn === true;
|
||||
|
||||
// 공정 스크롤 버튼 표시 여부 확인
|
||||
const checkScrollButtons = () => {
|
||||
const container = chipsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const isScrollable = container.scrollWidth > container.clientWidth;
|
||||
if (isScrollable) {
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const maxScroll = container.scrollWidth - container.clientWidth;
|
||||
setShowLeftBtn(scrollLeft > 5);
|
||||
setShowRightBtn(scrollLeft < maxScroll - 5);
|
||||
} else {
|
||||
setShowLeftBtn(false);
|
||||
setShowRightBtn(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 공정으로 스크롤
|
||||
const scrollToCurrentProcess = () => {
|
||||
const container = chipsRef.current;
|
||||
if (!container || !workOrder.processFlow) return;
|
||||
|
||||
let targetIndex = -1;
|
||||
|
||||
// 내 공정 우선
|
||||
if (selectedProcess) {
|
||||
targetIndex = workOrder.processFlow.findIndex(
|
||||
(step) =>
|
||||
step.id === selectedProcess.id &&
|
||||
(step.status === "current" || step.status === "pending")
|
||||
);
|
||||
}
|
||||
|
||||
// 없으면 현재 진행 중인 공정
|
||||
if (targetIndex === -1) {
|
||||
targetIndex = workOrder.processFlow.findIndex((step) => step.status === "current");
|
||||
}
|
||||
|
||||
if (targetIndex === -1) return;
|
||||
|
||||
const chips = container.querySelectorAll(".pop-process-chip");
|
||||
if (chips.length > targetIndex) {
|
||||
const targetChip = chips[targetIndex] as HTMLElement;
|
||||
const scrollPos =
|
||||
targetChip.offsetLeft - container.clientWidth / 2 + targetChip.offsetWidth / 2;
|
||||
container.scrollLeft = Math.max(0, scrollPos);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToCurrentProcess();
|
||||
checkScrollButtons();
|
||||
|
||||
const container = chipsRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", checkScrollButtons);
|
||||
return () => container.removeEventListener("scroll", checkScrollButtons);
|
||||
}
|
||||
}, [workOrder, selectedProcess]);
|
||||
|
||||
const handleScroll = (direction: "left" | "right", e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const container = chipsRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const scrollAmount = 150;
|
||||
container.scrollLeft += direction === "left" ? -scrollAmount : scrollAmount;
|
||||
setTimeout(checkScrollButtons, 100);
|
||||
};
|
||||
|
||||
// 상태 텍스트 결정
|
||||
const statusText =
|
||||
isReturnWork && currentStatus === "pending-accept" ? "리턴" : STATUS_TEXT[workOrder.status];
|
||||
const statusClass = isReturnWork ? "return" : workOrder.status;
|
||||
|
||||
// 완료된 공정 수
|
||||
const completedCount = workOrder.processFlow.filter((s) => s.status === "completed").length;
|
||||
const totalCount = workOrder.processFlow.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pop-work-card ${isReturnWork ? "return-card" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="pop-work-card-header">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)", flex: 1, flexWrap: "wrap" }}>
|
||||
<span className="pop-work-number">{workOrder.id}</span>
|
||||
{isReturnWork && <span className="pop-return-badge">리턴</span>}
|
||||
{workOrder.acceptedQuantity && workOrder.acceptedQuantity > 0 && workOrder.acceptedQuantity < workOrder.orderQuantity && (
|
||||
<span className="pop-partial-badge">
|
||||
{workOrder.acceptedQuantity}/{workOrder.orderQuantity} 접수
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`pop-work-status ${statusClass}`}>{statusText}</span>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{currentStatus === "pending-accept" && (
|
||||
<div className="pop-work-card-actions">
|
||||
<button
|
||||
className="pop-btn pop-btn-sm pop-btn-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
>
|
||||
접수
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{currentStatus === "in-progress" && (
|
||||
<div className="pop-work-card-actions">
|
||||
<button
|
||||
className="pop-btn pop-btn-sm pop-btn-ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancelAccept();
|
||||
}}
|
||||
>
|
||||
접수취소
|
||||
</button>
|
||||
<button
|
||||
className="pop-btn pop-btn-sm pop-btn-success"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartProduction();
|
||||
}}
|
||||
>
|
||||
생산진행
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리턴 정보 배너 */}
|
||||
{isReturnWork && currentStatus === "pending-accept" && (
|
||||
<div className="pop-return-banner">
|
||||
<span className="pop-return-banner-icon">🔄</span>
|
||||
<div>
|
||||
<div className="pop-return-banner-title">
|
||||
{workOrder.returnFromProcessName} 공정에서 리턴됨
|
||||
</div>
|
||||
<div className="pop-return-banner-reason">{workOrder.returnReason || "사유 없음"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바디 */}
|
||||
<div className="pop-work-card-body">
|
||||
<div className="pop-work-info-line">
|
||||
<div className="pop-work-info-item">
|
||||
<span className="pop-work-info-label">품목</span>
|
||||
<span className="pop-work-info-value">{workOrder.itemName}</span>
|
||||
</div>
|
||||
<div className="pop-work-info-item">
|
||||
<span className="pop-work-info-label">규격</span>
|
||||
<span className="pop-work-info-value">{workOrder.spec}</span>
|
||||
</div>
|
||||
<div className="pop-work-info-item">
|
||||
<span className="pop-work-info-label">지시</span>
|
||||
<span className="pop-work-info-value">{workOrder.orderQuantity}</span>
|
||||
</div>
|
||||
<div className="pop-work-info-item">
|
||||
<span className="pop-work-info-label">납기</span>
|
||||
<span className="pop-work-info-value">{workOrder.dueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 타임라인 */}
|
||||
<div className="pop-process-timeline">
|
||||
<div className="pop-process-bar">
|
||||
<div className="pop-process-bar-header">
|
||||
<span className="pop-process-bar-label">공정 진행</span>
|
||||
<span className="pop-process-bar-count">
|
||||
<span>{completedCount}</span>/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-process-segments">
|
||||
{workOrder.processFlow.map((step, index) => {
|
||||
let segmentClass = "";
|
||||
if (step.status === "completed") segmentClass = "done";
|
||||
else if (step.status === "current") segmentClass = "current";
|
||||
if (selectedProcess && step.id === selectedProcess.id) {
|
||||
segmentClass += " my-work";
|
||||
}
|
||||
return <div key={index} className={`pop-process-segment ${segmentClass}`} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-process-chips-container">
|
||||
<button
|
||||
className={`pop-process-scroll-btn left ${!showLeftBtn ? "hidden" : ""}`}
|
||||
onClick={(e) => handleScroll("left", e)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div className="pop-process-chips" ref={chipsRef}>
|
||||
{workOrder.processFlow.map((step, index) => {
|
||||
let chipClass = "";
|
||||
if (step.status === "completed") chipClass = "done";
|
||||
else if (step.status === "current") chipClass = "current";
|
||||
if (selectedProcess && step.id === selectedProcess.id) {
|
||||
chipClass += " my-work";
|
||||
}
|
||||
return (
|
||||
<div key={index} className={`pop-process-chip ${chipClass}`}>
|
||||
<span className="pop-chip-num">{index + 1}</span>
|
||||
{step.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className={`pop-process-scroll-btn right ${!showRightBtn ? "hidden" : ""}`}
|
||||
onClick={(e) => handleScroll("right", e)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 바 */}
|
||||
{workOrder.status !== "completed" && (
|
||||
<div className="pop-work-progress">
|
||||
<div className="pop-progress-info">
|
||||
<span className="pop-progress-text">
|
||||
{workOrder.producedQuantity} / {workOrder.orderQuantity} EA
|
||||
</span>
|
||||
<span className="pop-progress-percent">{progress}%</span>
|
||||
</div>
|
||||
<div className="pop-progress-bar">
|
||||
<div className="pop-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ActivityItem } from "./types";
|
||||
|
||||
interface ActivityListProps {
|
||||
items: ActivityItem[];
|
||||
onMoreClick: () => void;
|
||||
}
|
||||
|
||||
export function ActivityList({ items, onMoreClick }: ActivityListProps) {
|
||||
return (
|
||||
<div className="pop-dashboard-card">
|
||||
<div className="pop-dashboard-card-header">
|
||||
<h3 className="pop-dashboard-card-title">최근 활동</h3>
|
||||
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
|
||||
전체보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="pop-dashboard-activity-list">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="pop-dashboard-activity-item">
|
||||
<span className="pop-dashboard-activity-time">{item.time}</span>
|
||||
<span className={`pop-dashboard-activity-dot ${item.category}`} />
|
||||
<div className="pop-dashboard-activity-content">
|
||||
<div className="pop-dashboard-activity-title">{item.title}</div>
|
||||
<div className="pop-dashboard-activity-desc">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface DashboardFooterProps {
|
||||
companyName: string;
|
||||
version: string;
|
||||
emergencyContact: string;
|
||||
}
|
||||
|
||||
export function DashboardFooter({
|
||||
companyName,
|
||||
version,
|
||||
emergencyContact,
|
||||
}: DashboardFooterProps) {
|
||||
return (
|
||||
<footer className="pop-dashboard-footer">
|
||||
<span>© 2024 {companyName}</span>
|
||||
<span>Version {version}</span>
|
||||
<span>긴급연락: {emergencyContact}</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
theme: "dark" | "light";
|
||||
weather: WeatherInfo;
|
||||
user: UserInfo;
|
||||
company: CompanyInfo;
|
||||
onThemeToggle: () => void;
|
||||
onUserClick: () => void;
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
theme,
|
||||
weather,
|
||||
user,
|
||||
company,
|
||||
onThemeToggle,
|
||||
onUserClick,
|
||||
}: DashboardHeaderProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="pop-dashboard-header">
|
||||
<div className="pop-dashboard-header-left">
|
||||
<div className="pop-dashboard-time-display">
|
||||
<div className="pop-dashboard-time-main">
|
||||
{mounted ? formatTime(currentTime) : "--:--:--"}
|
||||
</div>
|
||||
<div className="pop-dashboard-time-date">
|
||||
{mounted ? formatDate(currentTime) : "----.--.--"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pop-dashboard-header-right">
|
||||
{/* 테마 토글 */}
|
||||
<button
|
||||
className="pop-dashboard-theme-toggle"
|
||||
onClick={onThemeToggle}
|
||||
title="테마 변경"
|
||||
>
|
||||
{theme === "dark" ? <Moon size={16} /> : <Sun size={16} />}
|
||||
</button>
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
<div className="pop-dashboard-weather">
|
||||
<span className="pop-dashboard-weather-temp">{weather.temp}</span>
|
||||
<span className="pop-dashboard-weather-desc">{weather.description}</span>
|
||||
</div>
|
||||
|
||||
{/* 회사 정보 */}
|
||||
<div className="pop-dashboard-company">
|
||||
<div className="pop-dashboard-company-name">{company.name}</div>
|
||||
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 배지 */}
|
||||
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||
<div className="pop-dashboard-user-text">
|
||||
<div className="pop-dashboard-user-name">{user.name}</div>
|
||||
<div className="pop-dashboard-user-role">{user.role}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { KpiItem } from "./types";
|
||||
|
||||
interface KpiBarProps {
|
||||
items: KpiItem[];
|
||||
}
|
||||
|
||||
export function KpiBar({ items }: KpiBarProps) {
|
||||
const getStrokeDashoffset = (percentage: number) => {
|
||||
const circumference = 264; // 2 * PI * 42
|
||||
return circumference - (circumference * percentage) / 100;
|
||||
};
|
||||
|
||||
const formatValue = (value: number) => {
|
||||
if (value >= 1000) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-dashboard-kpi-bar">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="pop-dashboard-kpi-item">
|
||||
<div className="pop-dashboard-kpi-gauge">
|
||||
<svg viewBox="0 0 100 100" width="52" height="52">
|
||||
<circle
|
||||
className="pop-dashboard-kpi-gauge-bg"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="42"
|
||||
/>
|
||||
<circle
|
||||
className={`pop-dashboard-kpi-gauge-fill kpi-color-${item.color}`}
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="42"
|
||||
strokeDasharray="264"
|
||||
strokeDashoffset={getStrokeDashoffset(item.percentage)}
|
||||
/>
|
||||
</svg>
|
||||
<span className={`pop-dashboard-kpi-gauge-text kpi-color-${item.color}`}>
|
||||
{item.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pop-dashboard-kpi-info">
|
||||
<div className="pop-dashboard-kpi-label">{item.label}</div>
|
||||
<div className={`pop-dashboard-kpi-value kpi-color-${item.color}`}>
|
||||
{formatValue(item.value)}
|
||||
<span className="pop-dashboard-kpi-unit">{item.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MenuItem } from "./types";
|
||||
|
||||
interface MenuGridProps {
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
export function MenuGrid({ items }: MenuGridProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = (item: MenuItem) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
} else {
|
||||
router.push(item.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pop-dashboard-menu-grid">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`pop-dashboard-menu-card ${item.category}`}
|
||||
onClick={() => handleClick(item)}
|
||||
>
|
||||
<div className="pop-dashboard-menu-header">
|
||||
<div className="pop-dashboard-menu-title">{item.title}</div>
|
||||
<div className={`pop-dashboard-menu-count ${item.category}`}>
|
||||
{item.count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pop-dashboard-menu-desc">{item.description}</div>
|
||||
<div className="pop-dashboard-menu-status">{item.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface NoticeBannerProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function NoticeBanner({ text }: NoticeBannerProps) {
|
||||
return (
|
||||
<div className="pop-dashboard-notice-banner">
|
||||
<div className="pop-dashboard-notice-label">공지</div>
|
||||
<div className="pop-dashboard-notice-content">
|
||||
<div className="pop-dashboard-notice-marquee">
|
||||
<span className="pop-dashboard-notice-text">{text}</span>
|
||||
<span className="pop-dashboard-notice-text">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { NoticeItem } from "./types";
|
||||
|
||||
interface NoticeListProps {
|
||||
items: NoticeItem[];
|
||||
onMoreClick: () => void;
|
||||
}
|
||||
|
||||
export function NoticeList({ items, onMoreClick }: NoticeListProps) {
|
||||
return (
|
||||
<div className="pop-dashboard-card">
|
||||
<div className="pop-dashboard-card-header">
|
||||
<h3 className="pop-dashboard-card-title">공지사항</h3>
|
||||
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
|
||||
더보기
|
||||
</button>
|
||||
</div>
|
||||
<div className="pop-dashboard-notice-list">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="pop-dashboard-notice-item">
|
||||
<div className="pop-dashboard-notice-title">{item.title}</div>
|
||||
<div className="pop-dashboard-notice-date">{item.date}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
import { NoticeBanner } from "./NoticeBanner";
|
||||
import { KpiBar } from "./KpiBar";
|
||||
import { MenuGrid } from "./MenuGrid";
|
||||
import { ActivityList } from "./ActivityList";
|
||||
import { NoticeList } from "./NoticeList";
|
||||
import { DashboardFooter } from "./DashboardFooter";
|
||||
import {
|
||||
KPI_ITEMS,
|
||||
MENU_ITEMS,
|
||||
ACTIVITY_ITEMS,
|
||||
NOTICE_ITEMS,
|
||||
NOTICE_MARQUEE_TEXT,
|
||||
} from "./data";
|
||||
import "./dashboard.css";
|
||||
|
||||
export function PopDashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
|
||||
// 로컬 스토리지에서 테마 로드
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const newTheme = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("popTheme", newTheme);
|
||||
};
|
||||
|
||||
const handleUserClick = () => {
|
||||
if (confirm("로그아웃 하시겠습니까?")) {
|
||||
alert("로그아웃되었습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivityMore = () => {
|
||||
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||
};
|
||||
|
||||
const handleNoticeMore = () => {
|
||||
alert("전체 공지사항 화면으로 이동합니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`pop-dashboard-container ${theme === "light" ? "light" : ""}`}>
|
||||
<div className="pop-dashboard">
|
||||
<DashboardHeader
|
||||
theme={theme}
|
||||
weather={{ temp: "18°C", description: "맑음" }}
|
||||
user={{ name: "김철수", role: "생산1팀", avatar: "김" }}
|
||||
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
onUserClick={handleUserClick}
|
||||
/>
|
||||
|
||||
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||
|
||||
<KpiBar items={KPI_ITEMS} />
|
||||
|
||||
<MenuGrid items={MENU_ITEMS} />
|
||||
|
||||
<div className="pop-dashboard-bottom-section">
|
||||
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||
<NoticeList items={NOTICE_ITEMS} onMoreClick={handleNoticeMore} />
|
||||
</div>
|
||||
|
||||
<DashboardFooter
|
||||
companyName="탑씰"
|
||||
version="1.0.0"
|
||||
emergencyContact="042-XXX-XXXX"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,906 +0,0 @@
|
|||
/* ============================================
|
||||
POP 대시보드 스타일시트
|
||||
다크 모드 (사이버펑크) + 라이트 모드 (소프트 그레이 민트)
|
||||
============================================ */
|
||||
|
||||
/* ========== 다크 모드 (기본) ========== */
|
||||
.pop-dashboard-container {
|
||||
--db-bg-page: #080c15;
|
||||
--db-bg-card: linear-gradient(145deg, rgba(25, 35, 60, 0.9) 0%, rgba(18, 26, 47, 0.95) 100%);
|
||||
--db-bg-card-solid: #121a2f;
|
||||
--db-bg-card-alt: rgba(0, 0, 0, 0.2);
|
||||
--db-bg-elevated: #202d4b;
|
||||
|
||||
--db-accent-primary: #00d4ff;
|
||||
--db-accent-primary-light: #00f0ff;
|
||||
--db-indigo: #4169e1;
|
||||
--db-violet: #8a2be2;
|
||||
--db-mint: #00d4ff;
|
||||
--db-emerald: #00ff88;
|
||||
--db-amber: #ffaa00;
|
||||
--db-rose: #ff3333;
|
||||
|
||||
--db-text-primary: #ffffff;
|
||||
--db-text-secondary: #b4c3dc;
|
||||
--db-text-muted: #64788c;
|
||||
|
||||
--db-border: rgba(40, 55, 85, 1);
|
||||
--db-border-light: rgba(55, 75, 110, 1);
|
||||
|
||||
--db-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--db-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--db-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
--db-glow-accent: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
|
||||
|
||||
--db-radius-sm: 6px;
|
||||
--db-radius-md: 10px;
|
||||
--db-radius-lg: 14px;
|
||||
|
||||
--db-card-border-production: rgba(65, 105, 225, 0.5);
|
||||
--db-card-border-material: rgba(138, 43, 226, 0.5);
|
||||
--db-card-border-quality: rgba(0, 212, 255, 0.5);
|
||||
--db-card-border-equipment: rgba(0, 255, 136, 0.5);
|
||||
--db-card-border-safety: rgba(255, 170, 0, 0.5);
|
||||
|
||||
--db-notice-bg: rgba(255, 170, 0, 0.1);
|
||||
--db-notice-border: rgba(255, 170, 0, 0.3);
|
||||
--db-notice-text: #ffaa00;
|
||||
|
||||
--db-weather-bg: rgba(0, 0, 0, 0.2);
|
||||
--db-weather-border: rgba(40, 55, 85, 1);
|
||||
|
||||
--db-user-badge-bg: rgba(0, 0, 0, 0.3);
|
||||
--db-user-badge-hover: rgba(0, 212, 255, 0.1);
|
||||
|
||||
--db-btn-more-bg: rgba(0, 212, 255, 0.08);
|
||||
--db-btn-more-border: rgba(0, 212, 255, 0.2);
|
||||
--db-btn-more-color: #00d4ff;
|
||||
|
||||
--db-status-bg: rgba(0, 212, 255, 0.1);
|
||||
--db-status-border: rgba(0, 212, 255, 0.2);
|
||||
--db-status-color: #00d4ff;
|
||||
}
|
||||
|
||||
/* ========== 라이트 모드 ========== */
|
||||
.pop-dashboard-container.light {
|
||||
--db-bg-page: #f8f9fb;
|
||||
--db-bg-card: #ffffff;
|
||||
--db-bg-card-solid: #ffffff;
|
||||
--db-bg-card-alt: #f3f5f7;
|
||||
--db-bg-elevated: #fafbfc;
|
||||
|
||||
--db-accent-primary: #14b8a6;
|
||||
--db-accent-primary-light: #2dd4bf;
|
||||
--db-indigo: #6366f1;
|
||||
--db-violet: #8b5cf6;
|
||||
--db-mint: #14b8a6;
|
||||
--db-emerald: #10b981;
|
||||
--db-amber: #f59e0b;
|
||||
--db-rose: #f43f5e;
|
||||
|
||||
--db-text-primary: #1e293b;
|
||||
--db-text-secondary: #475569;
|
||||
--db-text-muted: #94a3b8;
|
||||
|
||||
--db-border: #e2e8f0;
|
||||
--db-border-light: #f1f5f9;
|
||||
|
||||
--db-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--db-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||
--db-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
--db-glow-accent: none;
|
||||
|
||||
--db-card-border-production: rgba(99, 102, 241, 0.3);
|
||||
--db-card-border-material: rgba(139, 92, 246, 0.3);
|
||||
--db-card-border-quality: rgba(20, 184, 166, 0.3);
|
||||
--db-card-border-equipment: rgba(16, 185, 129, 0.3);
|
||||
--db-card-border-safety: rgba(245, 158, 11, 0.3);
|
||||
|
||||
--db-notice-bg: linear-gradient(90deg, rgba(245, 158, 11, 0.08), rgba(251, 191, 36, 0.05));
|
||||
--db-notice-border: rgba(245, 158, 11, 0.2);
|
||||
--db-notice-text: #475569;
|
||||
|
||||
--db-weather-bg: rgba(20, 184, 166, 0.08);
|
||||
--db-weather-border: rgba(20, 184, 166, 0.25);
|
||||
|
||||
--db-user-badge-bg: #f3f5f7;
|
||||
--db-user-badge-hover: #e2e8f0;
|
||||
|
||||
--db-btn-more-bg: rgba(20, 184, 166, 0.08);
|
||||
--db-btn-more-border: rgba(20, 184, 166, 0.25);
|
||||
--db-btn-more-color: #0d9488;
|
||||
|
||||
--db-status-bg: #f3f5f7;
|
||||
--db-status-border: transparent;
|
||||
--db-status-color: #475569;
|
||||
}
|
||||
|
||||
/* ========== 기본 컨테이너 ========== */
|
||||
.pop-dashboard-container {
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
background: var(--db-bg-page);
|
||||
color: var(--db-text-primary);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 다크 모드 배경 그리드 */
|
||||
.pop-dashboard-container::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #080c15 0%, #0a0f1c 50%, #0d1323 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light::before {
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #f8fafc 50%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.pop-dashboard {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ========== 헤더 ========== */
|
||||
.pop-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 24px;
|
||||
background: var(--db-bg-card);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-lg);
|
||||
box-shadow: var(--db-shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--db-accent-primary), transparent);
|
||||
}
|
||||
|
||||
.pop-dashboard-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pop-dashboard-time-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pop-dashboard-time-main {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--db-accent-primary);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-time-main {
|
||||
text-shadow: var(--db-glow-accent);
|
||||
animation: neonFlicker 3s infinite;
|
||||
}
|
||||
|
||||
.pop-dashboard-time-date {
|
||||
font-size: 13px;
|
||||
color: var(--db-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-dashboard-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 테마 토글 */
|
||||
.pop-dashboard-theme-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--db-bg-card-alt);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-md);
|
||||
color: var(--db-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pop-dashboard-theme-toggle:hover {
|
||||
border-color: var(--db-accent-primary);
|
||||
color: var(--db-accent-primary);
|
||||
}
|
||||
|
||||
/* 날씨 정보 */
|
||||
.pop-dashboard-weather {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--db-weather-bg);
|
||||
border: 1px solid var(--db-weather-border);
|
||||
border-radius: var(--db-radius-md);
|
||||
}
|
||||
|
||||
.pop-dashboard-weather-temp {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--db-amber);
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-weather-temp {
|
||||
color: var(--db-accent-primary);
|
||||
}
|
||||
|
||||
.pop-dashboard-weather-desc {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
}
|
||||
|
||||
/* 회사 정보 */
|
||||
.pop-dashboard-company {
|
||||
padding-right: 14px;
|
||||
border-right: 1px solid var(--db-border);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pop-dashboard-company-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--db-text-primary);
|
||||
}
|
||||
|
||||
.pop-dashboard-company-sub {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 사용자 배지 */
|
||||
.pop-dashboard-user-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--db-user-badge-bg);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pop-dashboard-user-badge:hover {
|
||||
background: var(--db-user-badge-hover);
|
||||
}
|
||||
|
||||
.pop-dashboard-user-badge:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.pop-dashboard-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: linear-gradient(135deg, var(--db-accent-primary), var(--db-emerald));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-user-avatar {
|
||||
box-shadow: 0 0 10px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-user-avatar {
|
||||
box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.pop-dashboard-user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--db-text-primary);
|
||||
}
|
||||
|
||||
.pop-dashboard-user-role {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
}
|
||||
|
||||
/* ========== 공지사항 배너 ========== */
|
||||
.pop-dashboard-notice-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--db-notice-bg);
|
||||
border: 1px solid var(--db-notice-border);
|
||||
border-radius: var(--db-radius-md);
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--db-bg-page);
|
||||
background: var(--db-amber);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-notice-label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-marquee {
|
||||
display: flex;
|
||||
animation: dashboardMarquee 30s linear infinite;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-text {
|
||||
font-size: 12px;
|
||||
color: var(--db-notice-text);
|
||||
padding-right: 100px;
|
||||
}
|
||||
|
||||
@keyframes dashboardMarquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-banner:hover .pop-dashboard-notice-marquee {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* ========== KPI 바 ========== */
|
||||
.pop-dashboard-kpi-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-item {
|
||||
background: var(--db-bg-card);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-lg);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: var(--db-shadow-sm);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--db-shadow-md);
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item:hover {
|
||||
border-color: rgba(0, 212, 255, 0.3);
|
||||
box-shadow: 0 0 30px rgba(0, 212, 255, 0.1), inset 0 0 30px rgba(0, 212, 255, 0.02);
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-gauge {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-gauge svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-gauge-bg {
|
||||
fill: none;
|
||||
stroke: var(--db-border);
|
||||
stroke-width: 5;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-gauge-fill {
|
||||
fill: none;
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-gauge-fill {
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-kpi-gauge-fill {
|
||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-gauge-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-info { flex: 1; }
|
||||
|
||||
.pop-dashboard-kpi-label {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-unit {
|
||||
font-size: 12px;
|
||||
color: var(--db-text-muted);
|
||||
margin-left: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* KPI 색상 */
|
||||
.kpi-color-cyan { color: var(--db-mint); stroke: var(--db-mint); }
|
||||
.kpi-color-emerald { color: var(--db-emerald); stroke: var(--db-emerald); }
|
||||
.kpi-color-rose { color: var(--db-rose); stroke: var(--db-rose); }
|
||||
.kpi-color-amber { color: var(--db-amber); stroke: var(--db-amber); }
|
||||
|
||||
/* ========== 메뉴 그리드 ========== */
|
||||
.pop-dashboard-menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-card {
|
||||
background: var(--db-bg-card);
|
||||
border-radius: var(--db-radius-lg);
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: var(--db-shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--db-shadow-lg);
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-card.production { border: 2px solid var(--db-card-border-production); }
|
||||
.pop-dashboard-menu-card.material { border: 2px solid var(--db-card-border-material); }
|
||||
.pop-dashboard-menu-card.quality { border: 2px solid var(--db-card-border-quality); }
|
||||
.pop-dashboard-menu-card.equipment { border: 2px solid var(--db-card-border-equipment); }
|
||||
.pop-dashboard-menu-card.safety { border: 2px solid var(--db-card-border-safety); }
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.production:hover { box-shadow: 0 0 20px rgba(65, 105, 225, 0.3); }
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.material:hover { box-shadow: 0 0 20px rgba(138, 43, 226, 0.3); }
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.quality:hover { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.equipment:hover { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.safety:hover { box-shadow: 0 0 20px rgba(255, 170, 0, 0.3); }
|
||||
|
||||
.pop-dashboard-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--db-text-primary);
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-count {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-menu-count {
|
||||
text-shadow: 0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-count.production { color: var(--db-indigo); }
|
||||
.pop-dashboard-menu-count.material { color: var(--db-violet); }
|
||||
.pop-dashboard-menu-count.quality { color: var(--db-mint); }
|
||||
.pop-dashboard-menu-count.equipment { color: var(--db-emerald); }
|
||||
.pop-dashboard-menu-count.safety { color: var(--db-amber); }
|
||||
|
||||
.pop-dashboard-menu-desc {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pop-dashboard-menu-status {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
background: var(--db-status-bg);
|
||||
border: 1px solid var(--db-status-border);
|
||||
border-radius: 16px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--db-status-color);
|
||||
}
|
||||
|
||||
/* ========== 하단 섹션 ========== */
|
||||
.pop-dashboard-bottom-section {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pop-dashboard-card {
|
||||
background: var(--db-bg-card);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-lg);
|
||||
padding: 18px;
|
||||
box-shadow: var(--db-shadow-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||
}
|
||||
|
||||
.pop-dashboard-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--db-border);
|
||||
}
|
||||
|
||||
.pop-dashboard-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--db-text-primary);
|
||||
}
|
||||
|
||||
.pop-dashboard-btn-more {
|
||||
padding: 6px 12px;
|
||||
background: var(--db-btn-more-bg);
|
||||
border: 1px solid var(--db-btn-more-border);
|
||||
color: var(--db-btn-more-color);
|
||||
border-radius: var(--db-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pop-dashboard-btn-more:hover {
|
||||
background: var(--db-accent-primary);
|
||||
color: white;
|
||||
border-color: var(--db-accent-primary);
|
||||
}
|
||||
|
||||
/* 활동 리스트 */
|
||||
.pop-dashboard-activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pop-dashboard-activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--db-bg-card-alt);
|
||||
border-radius: var(--db-radius-md);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item:hover {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border-color: rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-activity-item:hover {
|
||||
background: var(--db-border-light);
|
||||
}
|
||||
|
||||
.pop-dashboard-activity-time {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--db-accent-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-activity-time {
|
||||
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.pop-dashboard-activity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-activity-dot {
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
.pop-dashboard-activity-dot.production { background: var(--db-indigo); color: var(--db-indigo); }
|
||||
.pop-dashboard-activity-dot.material { background: var(--db-violet); color: var(--db-violet); }
|
||||
.pop-dashboard-activity-dot.quality { background: var(--db-mint); color: var(--db-mint); }
|
||||
.pop-dashboard-activity-dot.equipment { background: var(--db-emerald); color: var(--db-emerald); }
|
||||
|
||||
.pop-dashboard-activity-content { flex: 1; }
|
||||
|
||||
.pop-dashboard-activity-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--db-text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pop-dashboard-activity-desc {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
}
|
||||
|
||||
/* 공지사항 리스트 */
|
||||
.pop-dashboard-notice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-item {
|
||||
padding: 12px;
|
||||
background: var(--db-bg-card-alt);
|
||||
border-radius: var(--db-radius-md);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pop-dashboard-container:not(.light) .pop-dashboard-notice-item:hover {
|
||||
background: rgba(255, 170, 0, 0.05);
|
||||
}
|
||||
|
||||
.pop-dashboard-container.light .pop-dashboard-notice-item:hover {
|
||||
background: var(--db-border-light);
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--db-text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pop-dashboard-notice-date {
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ========== 푸터 ========== */
|
||||
.pop-dashboard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--db-bg-card);
|
||||
border: 1px solid var(--db-border);
|
||||
border-radius: var(--db-radius-md);
|
||||
font-size: 11px;
|
||||
color: var(--db-text-muted);
|
||||
}
|
||||
|
||||
/* ========== 반응형 ========== */
|
||||
|
||||
/* 가로 모드 */
|
||||
@media (orientation: landscape) {
|
||||
.pop-dashboard { padding: 16px 24px; }
|
||||
.pop-dashboard-kpi-bar { grid-template-columns: repeat(4, 1fr) !important; gap: 10px; }
|
||||
.pop-dashboard-kpi-item { padding: 12px 14px; }
|
||||
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
|
||||
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
|
||||
.pop-dashboard-kpi-value { font-size: 20px; }
|
||||
|
||||
.pop-dashboard-menu-grid { grid-template-columns: repeat(5, 1fr) !important; gap: 10px; }
|
||||
.pop-dashboard-menu-card { padding: 14px; display: block; }
|
||||
.pop-dashboard-menu-header { margin-bottom: 8px; display: block; }
|
||||
.pop-dashboard-menu-title { font-size: 13px; }
|
||||
.pop-dashboard-menu-count { font-size: 20px; }
|
||||
.pop-dashboard-menu-desc { display: block; font-size: 10px; }
|
||||
.pop-dashboard-menu-status { margin-top: 8px; }
|
||||
|
||||
.pop-dashboard-bottom-section { grid-template-columns: 2fr 1fr; }
|
||||
}
|
||||
|
||||
/* 세로 모드 */
|
||||
@media (orientation: portrait) {
|
||||
.pop-dashboard { padding: 16px; }
|
||||
.pop-dashboard-kpi-bar { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||
|
||||
.pop-dashboard-menu-grid { grid-template-columns: 1fr !important; gap: 8px; }
|
||||
.pop-dashboard-menu-card {
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.pop-dashboard-menu-header { margin-bottom: 0; display: flex; align-items: center; gap: 12px; }
|
||||
.pop-dashboard-menu-title { font-size: 15px; }
|
||||
.pop-dashboard-menu-count { font-size: 20px; }
|
||||
.pop-dashboard-menu-desc { display: none; }
|
||||
.pop-dashboard-menu-status { margin-top: 0; padding: 5px 12px; font-size: 11px; }
|
||||
|
||||
.pop-dashboard-bottom-section { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* 작은 화면 세로 */
|
||||
@media (max-width: 600px) and (orientation: portrait) {
|
||||
.pop-dashboard { padding: 12px; }
|
||||
.pop-dashboard-header { padding: 10px 14px; }
|
||||
.pop-dashboard-time-main { font-size: 20px; }
|
||||
.pop-dashboard-time-date { display: none; }
|
||||
.pop-dashboard-weather { padding: 4px 8px; }
|
||||
.pop-dashboard-weather-temp { font-size: 11px; }
|
||||
.pop-dashboard-weather-desc { display: none; }
|
||||
.pop-dashboard-company { display: none; }
|
||||
.pop-dashboard-user-text { display: none; }
|
||||
.pop-dashboard-user-avatar { width: 30px; height: 30px; }
|
||||
|
||||
.pop-dashboard-notice-banner { padding: 8px 12px; }
|
||||
.pop-dashboard-notice-label { font-size: 9px; }
|
||||
.pop-dashboard-notice-text { font-size: 11px; }
|
||||
|
||||
.pop-dashboard-kpi-item { padding: 12px 14px; gap: 10px; }
|
||||
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
|
||||
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
|
||||
.pop-dashboard-kpi-gauge-text { font-size: 10px; }
|
||||
.pop-dashboard-kpi-label { font-size: 10px; }
|
||||
.pop-dashboard-kpi-value { font-size: 18px; }
|
||||
|
||||
.pop-dashboard-menu-card { padding: 12px 16px; }
|
||||
.pop-dashboard-menu-title { font-size: 14px; }
|
||||
.pop-dashboard-menu-count { font-size: 18px; }
|
||||
.pop-dashboard-menu-status { padding: 4px 10px; font-size: 10px; }
|
||||
}
|
||||
|
||||
/* 작은 화면 가로 */
|
||||
@media (max-width: 600px) and (orientation: landscape) {
|
||||
.pop-dashboard { padding: 10px 16px; }
|
||||
.pop-dashboard-header { padding: 8px 12px; }
|
||||
.pop-dashboard-time-main { font-size: 18px; }
|
||||
.pop-dashboard-time-date { font-size: 10px; }
|
||||
.pop-dashboard-weather { display: none; }
|
||||
.pop-dashboard-company { display: none; }
|
||||
.pop-dashboard-user-text { display: none; }
|
||||
|
||||
.pop-dashboard-notice-banner { padding: 6px 10px; margin-bottom: 10px; }
|
||||
|
||||
.pop-dashboard-kpi-item { padding: 8px 10px; gap: 8px; }
|
||||
.pop-dashboard-kpi-gauge { width: 36px; height: 36px; }
|
||||
.pop-dashboard-kpi-gauge svg { width: 36px; height: 36px; }
|
||||
.pop-dashboard-kpi-gauge-text { font-size: 9px; }
|
||||
.pop-dashboard-kpi-label { font-size: 9px; }
|
||||
.pop-dashboard-kpi-value { font-size: 16px; }
|
||||
|
||||
.pop-dashboard-menu-card { padding: 10px; }
|
||||
.pop-dashboard-menu-title { font-size: 11px; }
|
||||
.pop-dashboard-menu-count { font-size: 16px; }
|
||||
.pop-dashboard-menu-status { margin-top: 4px; padding: 2px 6px; font-size: 8px; }
|
||||
}
|
||||
|
||||
/* ========== 애니메이션 ========== */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes neonFlicker {
|
||||
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
|
||||
20%, 24%, 55% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-item, .pop-dashboard-menu-card, .pop-dashboard-card {
|
||||
animation: fadeIn 0.35s ease-out backwards;
|
||||
}
|
||||
|
||||
.pop-dashboard-kpi-item:nth-child(1) { animation-delay: 0.05s; }
|
||||
.pop-dashboard-kpi-item:nth-child(2) { animation-delay: 0.1s; }
|
||||
.pop-dashboard-kpi-item:nth-child(3) { animation-delay: 0.15s; }
|
||||
.pop-dashboard-kpi-item:nth-child(4) { animation-delay: 0.2s; }
|
||||
|
||||
.pop-dashboard-menu-card:nth-child(1) { animation-delay: 0.1s; }
|
||||
.pop-dashboard-menu-card:nth-child(2) { animation-delay: 0.15s; }
|
||||
.pop-dashboard-menu-card:nth-child(3) { animation-delay: 0.2s; }
|
||||
.pop-dashboard-menu-card:nth-child(4) { animation-delay: 0.25s; }
|
||||
.pop-dashboard-menu-card:nth-child(5) { animation-delay: 0.3s; }
|
||||
|
||||
/* 스크롤바 */
|
||||
.pop-dashboard-container ::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.pop-dashboard-container ::-webkit-scrollbar-track { background: transparent; }
|
||||
.pop-dashboard-container ::-webkit-scrollbar-thumb { background: var(--db-border); border-radius: 3px; }
|
||||
.pop-dashboard-container ::-webkit-scrollbar-thumb:hover { background: var(--db-accent-primary); }
|
||||
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
// POP 대시보드 샘플 데이터
|
||||
|
||||
import { KpiItem, MenuItem, ActivityItem, NoticeItem } from "./types";
|
||||
|
||||
export const KPI_ITEMS: KpiItem[] = [
|
||||
{
|
||||
id: "achievement",
|
||||
label: "목표 달성률",
|
||||
value: 83.3,
|
||||
unit: "%",
|
||||
percentage: 83,
|
||||
color: "cyan",
|
||||
},
|
||||
{
|
||||
id: "production",
|
||||
label: "금일 생산실적",
|
||||
value: 1250,
|
||||
unit: "EA",
|
||||
percentage: 100,
|
||||
color: "emerald",
|
||||
},
|
||||
{
|
||||
id: "defect",
|
||||
label: "불량률",
|
||||
value: 0.8,
|
||||
unit: "%",
|
||||
percentage: 1,
|
||||
color: "rose",
|
||||
},
|
||||
{
|
||||
id: "equipment",
|
||||
label: "가동 설비",
|
||||
value: 8,
|
||||
unit: "/ 10",
|
||||
percentage: 80,
|
||||
color: "amber",
|
||||
},
|
||||
];
|
||||
|
||||
export const MENU_ITEMS: MenuItem[] = [
|
||||
{
|
||||
id: "production",
|
||||
title: "생산관리",
|
||||
count: 5,
|
||||
description: "작업지시 / 생산실적 / 공정관리",
|
||||
status: "진행중",
|
||||
category: "production",
|
||||
href: "/pop/work",
|
||||
},
|
||||
{
|
||||
id: "material",
|
||||
title: "자재관리",
|
||||
count: 12,
|
||||
description: "자재출고 / 재고확인 / 입고처리",
|
||||
status: "대기",
|
||||
category: "material",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
title: "품질관리",
|
||||
count: 3,
|
||||
description: "품질검사 / 불량처리 / 검사기록",
|
||||
status: "검사대기",
|
||||
category: "quality",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "equipment",
|
||||
title: "설비관리",
|
||||
count: 2,
|
||||
description: "설비현황 / 점검관리 / 고장신고",
|
||||
status: "점검필요",
|
||||
category: "equipment",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
id: "safety",
|
||||
title: "안전관리",
|
||||
count: 0,
|
||||
description: "안전점검 / 사고신고 / 안전교육",
|
||||
status: "이상무",
|
||||
category: "safety",
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
export const ACTIVITY_ITEMS: ActivityItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
time: "14:25",
|
||||
title: "생산실적 등록",
|
||||
description: "WO-2024-156 - 500EA 생산완료",
|
||||
category: "production",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
time: "13:50",
|
||||
title: "자재출고",
|
||||
description: "알루미늄 프로파일 A100 - 200EA",
|
||||
category: "material",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
time: "11:30",
|
||||
title: "품질검사 완료",
|
||||
description: "LOT-2024-156 합격 (불량 0건)",
|
||||
category: "quality",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
time: "09:15",
|
||||
title: "설비점검",
|
||||
description: "5호기 정기점검 완료",
|
||||
category: "equipment",
|
||||
},
|
||||
];
|
||||
|
||||
export const NOTICE_ITEMS: NoticeItem[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "금일 15:00 전체 안전교육",
|
||||
date: "2024-01-05",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "3호기 정기점검 안내",
|
||||
date: "2024-01-04",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "11월 우수팀 - 생산1팀",
|
||||
date: "2024-01-03",
|
||||
},
|
||||
];
|
||||
|
||||
export const NOTICE_MARQUEE_TEXT =
|
||||
"[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 11월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
|
||||
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
export { PopDashboard } from "./PopDashboard";
|
||||
export { DashboardHeader } from "./DashboardHeader";
|
||||
export { NoticeBanner } from "./NoticeBanner";
|
||||
export { KpiBar } from "./KpiBar";
|
||||
export { MenuGrid } from "./MenuGrid";
|
||||
export { ActivityList } from "./ActivityList";
|
||||
export { NoticeList } from "./NoticeList";
|
||||
export { DashboardFooter } from "./DashboardFooter";
|
||||
export * from "./types";
|
||||
export * from "./data";
|
||||
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
// POP 대시보드 타입 정의
|
||||
|
||||
export interface KpiItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
description: string;
|
||||
status: string;
|
||||
category: "production" | "material" | "quality" | "equipment" | "safety";
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
time: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: "production" | "material" | "quality" | "equipment";
|
||||
}
|
||||
|
||||
export interface NoticeItem {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface WeatherInfo {
|
||||
temp: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
name: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface CompanyInfo {
|
||||
name: string;
|
||||
subTitle: string;
|
||||
}
|
||||
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
// POP 샘플 데이터
|
||||
|
||||
import { Process, Equipment, WorkOrder, WorkStepTemplate } from "./types";
|
||||
|
||||
// 공정 목록
|
||||
export const PROCESSES: Process[] = [
|
||||
{ id: "P001", name: "절단", code: "CUT" },
|
||||
{ id: "P002", name: "용접", code: "WELD" },
|
||||
{ id: "P003", name: "도장", code: "PAINT" },
|
||||
{ id: "P004", name: "조립", code: "ASSY" },
|
||||
{ id: "P005", name: "검사", code: "QC" },
|
||||
{ id: "P006", name: "포장", code: "PACK" },
|
||||
{ id: "P007", name: "프레스", code: "PRESS" },
|
||||
{ id: "P008", name: "연마", code: "POLISH" },
|
||||
{ id: "P009", name: "열처리", code: "HEAT" },
|
||||
{ id: "P010", name: "표면처리", code: "SURFACE" },
|
||||
{ id: "P011", name: "드릴링", code: "DRILL" },
|
||||
{ id: "P012", name: "밀링", code: "MILL" },
|
||||
{ id: "P013", name: "선반", code: "LATHE" },
|
||||
{ id: "P014", name: "연삭", code: "GRIND" },
|
||||
{ id: "P015", name: "측정", code: "MEASURE" },
|
||||
{ id: "P016", name: "세척", code: "CLEAN" },
|
||||
{ id: "P017", name: "건조", code: "DRY" },
|
||||
{ id: "P018", name: "코팅", code: "COAT" },
|
||||
{ id: "P019", name: "라벨링", code: "LABEL" },
|
||||
{ id: "P020", name: "출하검사", code: "FINAL_QC" },
|
||||
];
|
||||
|
||||
// 설비 목록
|
||||
export const EQUIPMENTS: Equipment[] = [
|
||||
{
|
||||
id: "E001",
|
||||
name: "CNC-01",
|
||||
processIds: ["P001"],
|
||||
processNames: ["절단"],
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "E002",
|
||||
name: "CNC-02",
|
||||
processIds: ["P001"],
|
||||
processNames: ["절단"],
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "E003",
|
||||
name: "용접기-01",
|
||||
processIds: ["P002"],
|
||||
processNames: ["용접"],
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "E004",
|
||||
name: "도장라인-A",
|
||||
processIds: ["P003"],
|
||||
processNames: ["도장"],
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "E005",
|
||||
name: "조립라인-01",
|
||||
processIds: ["P004", "P006"],
|
||||
processNames: ["조립", "포장"],
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "E006",
|
||||
name: "검사대-01",
|
||||
processIds: ["P005"],
|
||||
processNames: ["검사"],
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "E007",
|
||||
name: "작업대-A",
|
||||
processIds: ["P001", "P002", "P004"],
|
||||
processNames: ["절단", "용접", "조립"],
|
||||
status: "idle",
|
||||
},
|
||||
{
|
||||
id: "E008",
|
||||
name: "작업대-B",
|
||||
processIds: ["P003", "P005", "P006"],
|
||||
processNames: ["도장", "검사", "포장"],
|
||||
status: "idle",
|
||||
},
|
||||
];
|
||||
|
||||
// 작업순서 템플릿
|
||||
export const WORK_STEP_TEMPLATES: Record<string, WorkStepTemplate[]> = {
|
||||
P001: [
|
||||
// 절단 공정
|
||||
{
|
||||
id: 1,
|
||||
name: "설비 점검",
|
||||
type: "equipment-check",
|
||||
description: "설비 상태 및 안전 점검",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "원자재 확인",
|
||||
type: "material-check",
|
||||
description: "원자재 수량 및 품질 확인",
|
||||
},
|
||||
{ id: 3, name: "설비 셋팅", type: "setup", description: "절단 조건 설정" },
|
||||
{ id: 4, name: "가공 작업", type: "work", description: "절단 가공 진행" },
|
||||
{
|
||||
id: 5,
|
||||
name: "품질 검사",
|
||||
type: "inspection",
|
||||
description: "가공 결과 품질 검사",
|
||||
},
|
||||
{ id: 6, name: "작업 기록", type: "record", description: "작업 실적 기록" },
|
||||
],
|
||||
P002: [
|
||||
// 용접 공정
|
||||
{
|
||||
id: 1,
|
||||
name: "설비 점검",
|
||||
type: "equipment-check",
|
||||
description: "용접기 및 안전장비 점검",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "자재 준비",
|
||||
type: "material-check",
|
||||
description: "용접 자재 및 부품 확인",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "용접 조건 설정",
|
||||
type: "setup",
|
||||
description: "전류, 전압 등 설정",
|
||||
},
|
||||
{ id: 4, name: "용접 작업", type: "work", description: "용접 진행" },
|
||||
{
|
||||
id: 5,
|
||||
name: "용접부 검사",
|
||||
type: "inspection",
|
||||
description: "용접 품질 검사",
|
||||
},
|
||||
{ id: 6, name: "작업 기록", type: "record", description: "용접 실적 기록" },
|
||||
],
|
||||
default: [
|
||||
{
|
||||
id: 1,
|
||||
name: "작업 준비",
|
||||
type: "preparation",
|
||||
description: "작업 전 준비사항 확인",
|
||||
},
|
||||
{ id: 2, name: "작업 실행", type: "work", description: "작업 진행" },
|
||||
{
|
||||
id: 3,
|
||||
name: "품질 확인",
|
||||
type: "inspection",
|
||||
description: "작업 결과 확인",
|
||||
},
|
||||
{ id: 4, name: "작업 기록", type: "record", description: "작업 내용 기록" },
|
||||
],
|
||||
};
|
||||
|
||||
// 작업지시 목록
|
||||
export const WORK_ORDERS: WorkOrder[] = [
|
||||
{
|
||||
id: "WO-2025-001",
|
||||
itemCode: "PROD-001",
|
||||
itemName: "LCD 패널 A101",
|
||||
spec: "1920x1080",
|
||||
orderQuantity: 500,
|
||||
producedQuantity: 0,
|
||||
status: "waiting",
|
||||
process: "P001",
|
||||
processName: "절단",
|
||||
equipment: "E001",
|
||||
equipmentName: "CNC-01",
|
||||
startDate: "2025-01-06",
|
||||
dueDate: "2025-01-10",
|
||||
priority: "high",
|
||||
accepted: false,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "pending" },
|
||||
{ id: "P007", name: "프레스", status: "pending" },
|
||||
{ id: "P011", name: "드릴링", status: "pending" },
|
||||
{ id: "P002", name: "용접", status: "pending" },
|
||||
{ id: "P008", name: "연마", status: "pending" },
|
||||
{ id: "P003", name: "도장", status: "pending" },
|
||||
{ id: "P004", name: "조립", status: "pending" },
|
||||
{ id: "P005", name: "검사", status: "pending" },
|
||||
{ id: "P006", name: "포장", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 0,
|
||||
},
|
||||
{
|
||||
id: "WO-2025-002",
|
||||
itemCode: "PROD-002",
|
||||
itemName: "LED 모듈 B202",
|
||||
spec: "500x500",
|
||||
orderQuantity: 300,
|
||||
producedQuantity: 150,
|
||||
status: "in-progress",
|
||||
process: "P002",
|
||||
processName: "용접",
|
||||
equipment: "E003",
|
||||
equipmentName: "용접기-01",
|
||||
startDate: "2025-01-05",
|
||||
dueDate: "2025-01-08",
|
||||
priority: "medium",
|
||||
accepted: true,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P007", name: "프레스", status: "completed" },
|
||||
{ id: "P011", name: "드릴링", status: "completed" },
|
||||
{ id: "P002", name: "용접", status: "current" },
|
||||
{ id: "P008", name: "연마", status: "pending" },
|
||||
{ id: "P003", name: "도장", status: "pending" },
|
||||
{ id: "P004", name: "조립", status: "pending" },
|
||||
{ id: "P005", name: "검사", status: "pending" },
|
||||
{ id: "P006", name: "포장", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 3,
|
||||
},
|
||||
{
|
||||
id: "WO-2025-003",
|
||||
itemCode: "PROD-003",
|
||||
itemName: "OLED 디스플레이",
|
||||
spec: "2560x1440",
|
||||
orderQuantity: 200,
|
||||
producedQuantity: 50,
|
||||
status: "in-progress",
|
||||
process: "P004",
|
||||
processName: "조립",
|
||||
equipment: "E005",
|
||||
equipmentName: "조립라인-01",
|
||||
startDate: "2025-01-04",
|
||||
dueDate: "2025-01-09",
|
||||
priority: "high",
|
||||
accepted: true,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P007", name: "프레스", status: "completed" },
|
||||
{ id: "P002", name: "용접", status: "completed" },
|
||||
{ id: "P003", name: "도장", status: "completed" },
|
||||
{ id: "P004", name: "조립", status: "current" },
|
||||
{ id: "P005", name: "검사", status: "pending" },
|
||||
{ id: "P006", name: "포장", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 4,
|
||||
},
|
||||
{
|
||||
id: "WO-2025-004",
|
||||
itemCode: "PROD-004",
|
||||
itemName: "스틸 프레임 C300",
|
||||
spec: "800x600",
|
||||
orderQuantity: 150,
|
||||
producedQuantity: 30,
|
||||
status: "in-progress",
|
||||
process: "P005",
|
||||
processName: "검사",
|
||||
equipment: "E006",
|
||||
equipmentName: "검사대-01",
|
||||
startDate: "2025-01-03",
|
||||
dueDate: "2025-01-10",
|
||||
priority: "medium",
|
||||
accepted: false,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P002", name: "용접", status: "completed" },
|
||||
{ id: "P008", name: "연마", status: "completed" },
|
||||
{ id: "P003", name: "도장", status: "completed" },
|
||||
{ id: "P004", name: "조립", status: "completed" },
|
||||
{ id: "P005", name: "검사", status: "current" },
|
||||
{ id: "P006", name: "포장", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 5,
|
||||
},
|
||||
{
|
||||
id: "WO-2025-005",
|
||||
itemCode: "PROD-005",
|
||||
itemName: "알루미늄 케이스",
|
||||
spec: "300x400",
|
||||
orderQuantity: 400,
|
||||
producedQuantity: 400,
|
||||
status: "completed",
|
||||
process: "P006",
|
||||
processName: "포장",
|
||||
equipment: "E005",
|
||||
equipmentName: "조립라인-01",
|
||||
startDate: "2025-01-01",
|
||||
dueDate: "2025-01-05",
|
||||
completedDate: "2025-01-05",
|
||||
priority: "high",
|
||||
accepted: true,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P007", name: "프레스", status: "completed" },
|
||||
{ id: "P008", name: "연마", status: "completed" },
|
||||
{ id: "P003", name: "도장", status: "completed" },
|
||||
{ id: "P004", name: "조립", status: "completed" },
|
||||
{ id: "P005", name: "검사", status: "completed" },
|
||||
{ id: "P006", name: "포장", status: "completed" },
|
||||
],
|
||||
currentProcessIndex: 6,
|
||||
},
|
||||
// 공정 리턴 작업지시
|
||||
{
|
||||
id: "WO-2025-006",
|
||||
itemCode: "PROD-006",
|
||||
itemName: "리턴품 샤프트 F100",
|
||||
spec: "50x300",
|
||||
orderQuantity: 80,
|
||||
producedQuantity: 30,
|
||||
status: "in-progress",
|
||||
process: "P008",
|
||||
processName: "연마",
|
||||
equipment: null,
|
||||
equipmentName: null,
|
||||
startDate: "2025-01-03",
|
||||
dueDate: "2025-01-08",
|
||||
priority: "high",
|
||||
accepted: false,
|
||||
isReturn: true,
|
||||
returnReason: "검사 불합격 - 표면 조도 미달",
|
||||
returnFromProcess: "P005",
|
||||
returnFromProcessName: "검사",
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P002", name: "용접", status: "completed" },
|
||||
{ id: "P008", name: "연마", status: "pending", isReturnTarget: true },
|
||||
{ id: "P014", name: "연삭", status: "pending" },
|
||||
{ id: "P016", name: "세척", status: "pending" },
|
||||
{ id: "P005", name: "검사", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 2,
|
||||
},
|
||||
// 분할접수 작업지시
|
||||
{
|
||||
id: "WO-2025-007",
|
||||
itemCode: "PROD-007",
|
||||
itemName: "분할접수 테스트 품목",
|
||||
spec: "100x200",
|
||||
orderQuantity: 200,
|
||||
producedQuantity: 50,
|
||||
acceptedQuantity: 50,
|
||||
remainingQuantity: 150,
|
||||
status: "in-progress",
|
||||
process: "P002",
|
||||
processName: "용접",
|
||||
equipment: "E003",
|
||||
equipmentName: "용접기-01",
|
||||
startDate: "2025-01-04",
|
||||
dueDate: "2025-01-10",
|
||||
priority: "normal",
|
||||
accepted: true,
|
||||
isPartialAccept: true,
|
||||
processFlow: [
|
||||
{ id: "P001", name: "절단", status: "completed" },
|
||||
{ id: "P002", name: "용접", status: "current" },
|
||||
{ id: "P003", name: "도장", status: "pending" },
|
||||
{ id: "P004", name: "조립", status: "pending" },
|
||||
{ id: "P005", name: "검사", status: "pending" },
|
||||
{ id: "P006", name: "포장", status: "pending" },
|
||||
],
|
||||
currentProcessIndex: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// 상태 텍스트 매핑
|
||||
export const STATUS_TEXT: Record<string, string> = {
|
||||
waiting: "대기",
|
||||
"in-progress": "진행중",
|
||||
completed: "완료",
|
||||
};
|
||||
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export { PopApp } from "./PopApp";
|
||||
export { PopHeader } from "./PopHeader";
|
||||
export { PopStatusTabs } from "./PopStatusTabs";
|
||||
export { PopWorkCard } from "./PopWorkCard";
|
||||
export { PopBottomNav } from "./PopBottomNav";
|
||||
export { PopEquipmentModal } from "./PopEquipmentModal";
|
||||
export { PopProcessModal } from "./PopProcessModal";
|
||||
export { PopAcceptModal } from "./PopAcceptModal";
|
||||
export { PopSettingsModal } from "./PopSettingsModal";
|
||||
export { PopProductionPanel } from "./PopProductionPanel";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./data";
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,104 +0,0 @@
|
|||
// POP 생산실적관리 타입 정의
|
||||
|
||||
export interface Process {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
processIds: string[];
|
||||
processNames: string[];
|
||||
status: "running" | "idle" | "maintenance";
|
||||
}
|
||||
|
||||
export interface ProcessFlowStep {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "pending" | "current" | "completed";
|
||||
isReturnTarget?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
orderQuantity: number;
|
||||
producedQuantity: number;
|
||||
status: "waiting" | "in-progress" | "completed";
|
||||
process: string;
|
||||
processName: string;
|
||||
equipment: string | null;
|
||||
equipmentName: string | null;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
completedDate?: string;
|
||||
priority: "high" | "medium" | "normal" | "low";
|
||||
accepted: boolean;
|
||||
processFlow: ProcessFlowStep[];
|
||||
currentProcessIndex: number;
|
||||
// 리턴 관련
|
||||
isReturn?: boolean;
|
||||
returnReason?: string;
|
||||
returnFromProcess?: string;
|
||||
returnFromProcessName?: string;
|
||||
// 분할접수 관련
|
||||
acceptedQuantity?: number;
|
||||
remainingQuantity?: number;
|
||||
isPartialAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkStepTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
type:
|
||||
| "equipment-check"
|
||||
| "material-check"
|
||||
| "setup"
|
||||
| "work"
|
||||
| "inspection"
|
||||
| "record"
|
||||
| "preparation";
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface WorkStep extends WorkStepTemplate {
|
||||
status: "pending" | "in-progress" | "completed";
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export type StatusType = "waiting" | "pending-accept" | "in-progress" | "completed";
|
||||
|
||||
export type ProductionType = "work-order" | "material";
|
||||
|
||||
export interface AppState {
|
||||
currentStatus: StatusType;
|
||||
selectedEquipment: Equipment | null;
|
||||
selectedProcess: Process | null;
|
||||
selectedWorkOrder: WorkOrder | null;
|
||||
showMyWorkOnly: boolean;
|
||||
currentWorkSteps: WorkStep[];
|
||||
currentStepIndex: number;
|
||||
currentProductionType: ProductionType;
|
||||
selectionMode: "single" | "multi";
|
||||
completionAction: "close" | "stay";
|
||||
acceptTargetWorkOrder: WorkOrder | null;
|
||||
acceptQuantity: number;
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
|
||||
export interface ModalState {
|
||||
equipment: boolean;
|
||||
process: boolean;
|
||||
accept: boolean;
|
||||
settings: boolean;
|
||||
}
|
||||
|
||||
export interface PanelState {
|
||||
production: boolean;
|
||||
}
|
||||
|
|
@ -27,14 +27,13 @@ interface EmbeddedScreenProps {
|
|||
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
*/
|
||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||
({ embedding, onSelectionChanged, position, initialFormData, groupedData }, ref) => {
|
||||
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
|
||||
const [layout, setLayout] = useState<ComponentData[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -431,8 +430,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
groupedData={groupedData}
|
||||
initialData={initialFormData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,14 +17,13 @@ interface ScreenSplitPanelProps {
|
|||
screenId?: number;
|
||||
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (수정 모드에서 원본 데이터 추적용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컴포넌트
|
||||
* 순수하게 화면 분할 기능만 제공합니다.
|
||||
*/
|
||||
export function ScreenSplitPanel({ screenId, config, initialFormData, groupedData }: ScreenSplitPanelProps) {
|
||||
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
|
||||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
|
|
@ -118,7 +117,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData, groupedDat
|
|||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||
{hasLeftScreen ? (
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} groupedData={groupedData} />
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||
|
|
@ -158,7 +157,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData, groupedDat
|
|||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||
{hasRightScreen ? (
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} groupedData={groupedData} />
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,7 +17,6 @@ import {
|
|||
Layout,
|
||||
Monitor,
|
||||
Square,
|
||||
Languages,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -35,8 +34,6 @@ interface DesignerToolbarProps {
|
|||
isSaving?: boolean;
|
||||
showZoneBorders?: boolean;
|
||||
onToggleZoneBorders?: () => void;
|
||||
onGenerateMultilang?: () => void;
|
||||
isGeneratingMultilang?: boolean;
|
||||
}
|
||||
|
||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||
|
|
@ -53,8 +50,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
isSaving = false,
|
||||
showZoneBorders = true,
|
||||
onToggleZoneBorders,
|
||||
onGenerateMultilang,
|
||||
isGeneratingMultilang = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white px-4 py-3 shadow-sm">
|
||||
|
|
@ -231,20 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
{onGenerateMultilang && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGenerateMultilang}
|
||||
disabled={isGeneratingMultilang}
|
||||
className="flex items-center space-x-1"
|
||||
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
|
|
|
|||
|
|
@ -309,10 +309,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
||||
// console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log("🔍 그룹 데이터 조회 시작:", {
|
||||
// tableName: modalState.tableName,
|
||||
// groupByColumns: modalState.groupByColumns,
|
||||
// editData: modalState.editData,
|
||||
// });
|
||||
|
||||
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
||||
const groupValues: Record<string, any> = {};
|
||||
modalState.groupByColumns.forEach((column) => {
|
||||
|
|
@ -322,9 +329,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
|
||||
if (Object.keys(groupValues).length === 0) {
|
||||
// console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("🔍 그룹 조회 요청:", {
|
||||
// tableName: modalState.tableName,
|
||||
// groupValues,
|
||||
// });
|
||||
|
||||
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
|
||||
|
|
@ -334,19 +347,23 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
enableEntityJoin: true,
|
||||
});
|
||||
|
||||
// console.log("🔍 그룹 조회 응답:", response);
|
||||
|
||||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
// console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
} else {
|
||||
console.warn("그룹 데이터가 없습니다:", response);
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("그룹 데이터 조회 오류:", error);
|
||||
console.error("❌ 그룹 데이터 조회 오류:", error);
|
||||
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
|
|
@ -654,11 +671,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||
|
||||
try {
|
||||
// screenId 전달하여 제어관리 실행 가능하도록 함
|
||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||
deletedItem.id,
|
||||
screenData.screenInfo.tableName,
|
||||
modalState.screenId || screenData.screenInfo?.id,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -746,74 +761,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// INSERT 모드
|
||||
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
|
||||
|
||||
// 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||
const dataToSave = { ...formData };
|
||||
const fieldsWithNumbering: Record<string, string> = {};
|
||||
|
||||
// formData에서 채번 규칙이 설정된 필드 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (key.endsWith("_numberingRuleId") && value) {
|
||||
const fieldName = key.replace("_numberingRuleId", "");
|
||||
fieldsWithNumbering[fieldName] = value as string;
|
||||
console.log(`🎯 [EditModal] 채번 규칙 발견: ${fieldName} → 규칙 ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
|
||||
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||||
console.log("🎯 [EditModal] 채번 규칙 할당 시작");
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
let hasAllocationFailure = false;
|
||||
const failedFields: string[] = [];
|
||||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
try {
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
const allocateResult = await allocateNumberingCode(ruleId);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
const newCode = allocateResult.data.generatedCode;
|
||||
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]} → ${newCode}`);
|
||||
dataToSave[fieldName] = newCode;
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
|
||||
hasAllocationFailure = true;
|
||||
failedFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 채번 규칙 할당 실패 시 저장 중단
|
||||
if (hasAllocationFailure) {
|
||||
const fieldNames = failedFields.join(", ");
|
||||
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
|
||||
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
|
||||
for (const key of Object.keys(dataToSave)) {
|
||||
if (key.endsWith("_numberingRuleId")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[EditModal] 최종 저장 데이터:", dataToSave);
|
||||
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId!,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: dataToSave,
|
||||
data: formData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -1026,18 +977,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
||||
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
|
||||
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
|
||||
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
|
||||
const hasUniversalFormModal = screenData.components.some(
|
||||
(c) => {
|
||||
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
|
||||
// 최상위에 universal-form-modal이 있는 경우
|
||||
if (c.componentType === "universal-form-modal") return true;
|
||||
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
|
||||
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
|
||||
if (c.componentType === "conditional-container") return true;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
|
||||
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
|
||||
|
||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||
const enrichedFormData = {
|
||||
|
|
@ -1079,9 +1029,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
|
||||
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
|
||||
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
|
||||
onSave={hasUniversalFormModal ? undefined : handleSave}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupedDataProp}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue