Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
127e54d26c
|
|
@ -0,0 +1,310 @@
|
||||||
|
# TableListComponent 개발 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
`TableListComponent`는 ERP 시스템의 핵심 데이터 그리드 컴포넌트입니다. DevExpress DataGrid 스타일의 고급 기능들을 구현하고 있습니다.
|
||||||
|
|
||||||
|
**파일 위치**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 핵심 기능 목록
|
||||||
|
|
||||||
|
### 1. 인라인 편집 (Inline Editing)
|
||||||
|
|
||||||
|
- 셀 더블클릭 또는 F2 키로 편집 모드 진입
|
||||||
|
- 직접 타이핑으로도 편집 모드 진입 가능
|
||||||
|
- Enter로 저장, Escape로 취소
|
||||||
|
- **컬럼별 편집 가능 여부 설정** (`editable` 속성)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ColumnConfig에서 editable 속성 사용
|
||||||
|
interface ColumnConfig {
|
||||||
|
editable?: boolean; // false면 해당 컬럼 인라인 편집 불가
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**편집 불가 컬럼 체크 필수 위치**:
|
||||||
|
1. `handleCellDoubleClick` - 더블클릭 편집
|
||||||
|
2. `onKeyDown` F2 케이스 - 키보드 편집
|
||||||
|
3. `onKeyDown` default 케이스 - 직접 타이핑 편집
|
||||||
|
4. 컨텍스트 메뉴 "셀 편집" 옵션
|
||||||
|
|
||||||
|
### 2. 배치 편집 (Batch Editing)
|
||||||
|
|
||||||
|
- 여러 셀 수정 후 일괄 저장/취소
|
||||||
|
- `pendingChanges` Map으로 변경사항 추적
|
||||||
|
- 저장 전 유효성 검증
|
||||||
|
|
||||||
|
### 3. 데이터 유효성 검증 (Validation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ValidationRule = {
|
||||||
|
required?: boolean;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: RegExp;
|
||||||
|
customMessage?: string;
|
||||||
|
validate?: (value: any, row: any) => string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 컬럼 헤더 필터 (Header Filter)
|
||||||
|
|
||||||
|
- 각 컬럼 헤더에 필터 아이콘
|
||||||
|
- 고유값 목록에서 다중 선택 필터링
|
||||||
|
- `headerFilters` Map으로 필터 상태 관리
|
||||||
|
|
||||||
|
### 5. 필터 빌더 (Filter Builder)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FilterCondition {
|
||||||
|
id: string;
|
||||||
|
column: string;
|
||||||
|
operator: "equals" | "notEquals" | "contains" | "notContains" |
|
||||||
|
"startsWith" | "endsWith" | "greaterThan" | "lessThan" |
|
||||||
|
"greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterGroup {
|
||||||
|
id: string;
|
||||||
|
logic: "AND" | "OR";
|
||||||
|
conditions: FilterCondition[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 검색 패널 (Search Panel)
|
||||||
|
|
||||||
|
- 전체 데이터 검색
|
||||||
|
- 검색어 하이라이팅
|
||||||
|
- `searchHighlights` Map으로 하이라이트 위치 관리
|
||||||
|
|
||||||
|
### 7. 엑셀 내보내기 (Excel Export)
|
||||||
|
|
||||||
|
- `xlsx` 라이브러리 사용
|
||||||
|
- 현재 표시 데이터 또는 전체 데이터 내보내기
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
const worksheet = XLSX.utils.json_to_sheet(exportData);
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
|
||||||
|
XLSX.writeFile(workbook, `${tableName}_${timestamp}.xlsx`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 클립보드 복사 (Copy to Clipboard)
|
||||||
|
|
||||||
|
- 선택된 행 또는 전체 데이터 복사
|
||||||
|
- 탭 구분자로 엑셀 붙여넣기 호환
|
||||||
|
|
||||||
|
### 9. 컨텍스트 메뉴 (Context Menu)
|
||||||
|
|
||||||
|
- 우클릭으로 메뉴 표시
|
||||||
|
- 셀 편집, 행 복사, 행 삭제 등 옵션
|
||||||
|
- 편집 불가 컬럼은 "(잠김)" 표시
|
||||||
|
|
||||||
|
### 10. 키보드 네비게이션
|
||||||
|
|
||||||
|
| 키 | 동작 |
|
||||||
|
|---|---|
|
||||||
|
| Arrow Keys | 셀 이동 |
|
||||||
|
| Tab | 다음 셀 |
|
||||||
|
| Shift+Tab | 이전 셀 |
|
||||||
|
| F2 | 편집 모드 |
|
||||||
|
| Enter | 저장 후 아래로 이동 |
|
||||||
|
| Escape | 편집 취소 |
|
||||||
|
| Ctrl+C | 복사 |
|
||||||
|
| Delete | 셀 값 삭제 |
|
||||||
|
|
||||||
|
### 11. 컬럼 리사이징
|
||||||
|
|
||||||
|
- 컬럼 헤더 경계 드래그로 너비 조절
|
||||||
|
- `columnWidths` 상태로 관리
|
||||||
|
- localStorage에 저장
|
||||||
|
|
||||||
|
### 12. 컬럼 순서 변경
|
||||||
|
|
||||||
|
- 드래그 앤 드롭으로 컬럼 순서 변경
|
||||||
|
- `columnOrder` 상태로 관리
|
||||||
|
- localStorage에 저장
|
||||||
|
|
||||||
|
### 13. 상태 영속성 (State Persistence)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// localStorage 키 패턴
|
||||||
|
const stateKey = `tableState_${tableName}_${userId}`;
|
||||||
|
|
||||||
|
// 저장되는 상태
|
||||||
|
interface TableState {
|
||||||
|
columnWidths: Record<string, number>;
|
||||||
|
columnOrder: string[];
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: "asc" | "desc";
|
||||||
|
frozenColumns: string[];
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14. 그룹화 및 그룹 소계
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupedData {
|
||||||
|
groupKey: string;
|
||||||
|
groupValues: Record<string, any>;
|
||||||
|
items: any[];
|
||||||
|
count: number;
|
||||||
|
summary?: Record<string, { sum: number; avg: number; count: number }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15. 총계 요약 (Total Summary)
|
||||||
|
|
||||||
|
- 숫자 컬럼의 합계, 평균, 개수 표시
|
||||||
|
- 테이블 하단에 요약 행 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 캐싱 전략
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 테이블 컬럼 캐시
|
||||||
|
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||||
|
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
|
// API 호출 디바운싱
|
||||||
|
const debouncedApiCall = <T extends any[], R>(
|
||||||
|
key: string,
|
||||||
|
fn: (...args: T) => Promise<R>,
|
||||||
|
delay: number = 300
|
||||||
|
) => { ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 필수 Import
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||||
|
import { TableListConfig, ColumnConfig } from "./types";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
import { codeCache } from "@/lib/caching/codeCache";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주요 상태 (State)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 데이터 관련
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
const [filteredData, setFilteredData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 편집 관련
|
||||||
|
const [editingCell, setEditingCell] = useState<{
|
||||||
|
rowIndex: number;
|
||||||
|
colIndex: number;
|
||||||
|
columnName: string;
|
||||||
|
originalValue: any;
|
||||||
|
} | null>(null);
|
||||||
|
const [editingValue, setEditingValue] = useState<string>("");
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
||||||
|
|
||||||
|
// 필터 관련
|
||||||
|
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||||
|
const [globalSearchText, setGlobalSearchText] = useState("");
|
||||||
|
const [searchHighlights, setSearchHighlights] = useState<Map<string, number[]>>(new Map());
|
||||||
|
|
||||||
|
// 컬럼 관련
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
||||||
|
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 선택 관련
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||||
|
|
||||||
|
// 정렬 관련
|
||||||
|
const [sortBy, setSortBy] = useState<string>("");
|
||||||
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 편집 불가 컬럼 구현 체크리스트
|
||||||
|
|
||||||
|
새로운 편집 진입점을 추가할 때 반드시 다음을 확인하세요:
|
||||||
|
|
||||||
|
- [ ] `column.editable === false` 체크 추가
|
||||||
|
- [ ] 편집 불가 시 `toast.warning()` 메시지 표시
|
||||||
|
- [ ] `return` 또는 `break`로 편집 모드 진입 방지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 표준 편집 불가 체크 패턴
|
||||||
|
const column = visibleColumns.find((col) => col.columnName === columnName);
|
||||||
|
if (column?.editable === false) {
|
||||||
|
toast.warning(`'${column.displayName || columnName}' 컬럼은 편집할 수 없습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 시각적 표시
|
||||||
|
|
||||||
|
### 편집 불가 컬럼 표시
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 헤더에 잠금 아이콘
|
||||||
|
{column.editable === false && (
|
||||||
|
<Lock className="ml-1 h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
// 셀 배경색
|
||||||
|
className={cn(
|
||||||
|
column.editable === false && "bg-gray-50 dark:bg-gray-900/30"
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 성능 최적화
|
||||||
|
|
||||||
|
1. **useMemo 사용**: `visibleColumns`, `filteredData`, `paginatedData` 등 계산 비용이 큰 값
|
||||||
|
2. **useCallback 사용**: 이벤트 핸들러 함수들
|
||||||
|
3. **디바운싱**: API 호출, 검색, 필터링
|
||||||
|
4. **캐싱**: 테이블 컬럼 정보, 코드 데이터
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
1. **visibleColumns 정의 순서**: `columnOrder`, `columnVisibility` 상태 이후에 정의해야 함
|
||||||
|
2. **editInputRef 타입 체크**: `select()` 호출 전 `instanceof HTMLInputElement` 확인
|
||||||
|
3. **localStorage 키**: `tableName`과 `userId`를 조합하여 고유하게 생성
|
||||||
|
4. **멀티테넌시**: 모든 API 호출에 `company_code` 필터링 적용 (백엔드에서 자동 처리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
- `frontend/lib/registry/components/table-list/types.ts` - 타입 정의
|
||||||
|
- `frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` - 설정 패널
|
||||||
|
- `frontend/components/common/TableOptionsModal.tsx` - 옵션 모달
|
||||||
|
- `frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx` - 스티키 헤더 테이블
|
||||||
|
|
@ -8,6 +8,7 @@ import path from "path";
|
||||||
import config from "./config/environment";
|
import config from "./config/environment";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { errorHandler } from "./middleware/errorHandler";
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||||
|
|
||||||
// 라우터 임포트
|
// 라우터 임포트
|
||||||
import authRoutes from "./routes/authRoutes";
|
import authRoutes from "./routes/authRoutes";
|
||||||
|
|
@ -74,6 +75,12 @@ import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
|
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||||
|
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||||
|
import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자동 입력 관리
|
||||||
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -168,6 +175,10 @@ const limiter = rateLimit({
|
||||||
});
|
});
|
||||||
app.use("/api/", limiter);
|
app.use("/api/", limiter);
|
||||||
|
|
||||||
|
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||||
|
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||||
|
app.use("/api/", refreshTokenIfNeeded);
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
@ -240,6 +251,12 @@ app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||||
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
|
||||||
|
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
|
||||||
|
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
|
||||||
|
app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 관리
|
||||||
|
app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -632,6 +632,9 @@ export class DashboardController {
|
||||||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연결 정보 (응답에 포함용)
|
||||||
|
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||||
|
|
||||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||||
if (externalConnectionId) {
|
if (externalConnectionId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -652,6 +655,11 @@ export class DashboardController {
|
||||||
if (connectionResult.success && connectionResult.data) {
|
if (connectionResult.success && connectionResult.data) {
|
||||||
const connection = connectionResult.data;
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 연결 정보 저장 (응답에 포함)
|
||||||
|
connectionInfo = {
|
||||||
|
saveToHistory: connection.save_to_history === "Y",
|
||||||
|
};
|
||||||
|
|
||||||
// 인증 헤더 생성 (DB 토큰 등)
|
// 인증 헤더 생성 (DB 토큰 등)
|
||||||
const authHeaders =
|
const authHeaders =
|
||||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||||
|
|
@ -694,6 +702,15 @@ export class DashboardController {
|
||||||
requestConfig.data = body;
|
requestConfig.data = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 디버깅 로그: 실제 요청 정보 출력
|
||||||
|
logger.info(`[fetchExternalApi] 요청 정보:`, {
|
||||||
|
url: requestConfig.url,
|
||||||
|
method: requestConfig.method,
|
||||||
|
headers: requestConfig.headers,
|
||||||
|
body: requestConfig.data,
|
||||||
|
externalConnectionId,
|
||||||
|
});
|
||||||
|
|
||||||
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
|
||||||
// ExternalRestApiConnectionService와 동일한 로직 적용
|
// ExternalRestApiConnectionService와 동일한 로직 적용
|
||||||
const bypassDomains = ["thiratis.com"];
|
const bypassDomains = ["thiratis.com"];
|
||||||
|
|
@ -709,9 +726,9 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
||||||
if (isKmaApi) {
|
if (isKmaApi) {
|
||||||
requestConfig.responseType = 'arraybuffer';
|
requestConfig.responseType = "arraybuffer";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios(requestConfig);
|
const response = await axios(requestConfig);
|
||||||
|
|
@ -727,18 +744,22 @@ export class DashboardController {
|
||||||
|
|
||||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||||
const iconv = require('iconv-lite');
|
const iconv = require("iconv-lite");
|
||||||
const buffer = Buffer.from(data);
|
const buffer = Buffer.from(data);
|
||||||
const utf8Text = buffer.toString('utf-8');
|
const utf8Text = buffer.toString("utf-8");
|
||||||
|
|
||||||
// UTF-8로 정상 디코딩되었는지 확인
|
// UTF-8로 정상 디코딩되었는지 확인
|
||||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
if (
|
||||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
utf8Text.includes("특보") ||
|
||||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
utf8Text.includes("경보") ||
|
||||||
|
utf8Text.includes("주의보") ||
|
||||||
|
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
|
||||||
|
) {
|
||||||
|
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||||
} else {
|
} else {
|
||||||
// EUC-KR로 디코딩
|
// EUC-KR로 디코딩
|
||||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 텍스트 응답인 경우 포맷팅
|
// 텍스트 응답인 경우 포맷팅
|
||||||
|
|
@ -749,6 +770,7 @@ export class DashboardController {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status || 500;
|
const status = error.response?.status || 500;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { ApiResponse } from "../types/common";
|
import { ApiResponse } from "../types/common";
|
||||||
import { Client } from "pg";
|
import { Client } from "pg";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne, getPool } from "../database/db";
|
||||||
import config from "../config/environment";
|
import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
|
|
@ -1256,8 +1256,17 @@ export async function updateMenu(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestCompanyCode =
|
let requestCompanyCode =
|
||||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
menuData.companyCode || menuData.company_code;
|
||||||
|
|
||||||
|
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
|
||||||
|
if (
|
||||||
|
requestCompanyCode === "none" ||
|
||||||
|
requestCompanyCode === "" ||
|
||||||
|
!requestCompanyCode
|
||||||
|
) {
|
||||||
|
requestCompanyCode = currentMenu.company_code;
|
||||||
|
}
|
||||||
|
|
||||||
// company_code 변경 시도하는 경우 권한 체크
|
// company_code 변경 시도하는 경우 권한 체크
|
||||||
if (requestCompanyCode !== currentMenu.company_code) {
|
if (requestCompanyCode !== currentMenu.company_code) {
|
||||||
|
|
@ -3406,3 +3415,395 @@ export async function copyMenu(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* 사원 + 부서 통합 관리 API
|
||||||
|
* ============================================================
|
||||||
|
*
|
||||||
|
* 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다.
|
||||||
|
*
|
||||||
|
* ## 핵심 기능
|
||||||
|
* 1. user_info 테이블에 사원 개인정보 저장
|
||||||
|
* 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장
|
||||||
|
* 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환
|
||||||
|
* 4. 트랜잭션으로 데이터 정합성 보장
|
||||||
|
*
|
||||||
|
* ## 요청 데이터 구조
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "userInfo": {
|
||||||
|
* "user_id": "string (필수)",
|
||||||
|
* "user_name": "string (필수)",
|
||||||
|
* "email": "string",
|
||||||
|
* "cell_phone": "string",
|
||||||
|
* "sabun": "string",
|
||||||
|
* ...
|
||||||
|
* },
|
||||||
|
* "mainDept": {
|
||||||
|
* "dept_code": "string (필수)",
|
||||||
|
* "dept_name": "string",
|
||||||
|
* "position_name": "string"
|
||||||
|
* },
|
||||||
|
* "subDepts": [
|
||||||
|
* {
|
||||||
|
* "dept_code": "string (필수)",
|
||||||
|
* "dept_name": "string",
|
||||||
|
* "position_name": "string"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 사원 + 부서 저장 요청 타입
|
||||||
|
interface UserWithDeptRequest {
|
||||||
|
userInfo: {
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
user_name_eng?: string;
|
||||||
|
user_password?: string;
|
||||||
|
email?: string;
|
||||||
|
tel?: string;
|
||||||
|
cell_phone?: string;
|
||||||
|
sabun?: string;
|
||||||
|
user_type?: string;
|
||||||
|
user_type_name?: string;
|
||||||
|
status?: string;
|
||||||
|
locale?: string;
|
||||||
|
// 메인 부서 정보 (user_info에도 저장)
|
||||||
|
dept_code?: string;
|
||||||
|
dept_name?: string;
|
||||||
|
position_code?: string;
|
||||||
|
position_name?: string;
|
||||||
|
};
|
||||||
|
mainDept?: {
|
||||||
|
dept_code: string;
|
||||||
|
dept_name?: string;
|
||||||
|
position_name?: string;
|
||||||
|
};
|
||||||
|
subDepts?: Array<{
|
||||||
|
dept_code: string;
|
||||||
|
dept_name?: string;
|
||||||
|
position_name?: string;
|
||||||
|
}>;
|
||||||
|
isUpdate?: boolean; // 수정 모드 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/users/with-dept
|
||||||
|
* 사원 + 부서 통합 저장 API
|
||||||
|
*/
|
||||||
|
export const saveUserWithDept = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const currentUserId = req.user?.userId;
|
||||||
|
|
||||||
|
logger.info("사원+부서 통합 저장 요청", {
|
||||||
|
userId: userInfo?.user_id,
|
||||||
|
mainDept: mainDept?.dept_code,
|
||||||
|
subDeptsCount: subDepts.length,
|
||||||
|
isUpdate,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필수값 검증
|
||||||
|
if (!userInfo?.user_id || !userInfo?.user_name) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자 ID와 이름은 필수입니다.",
|
||||||
|
error: { code: "REQUIRED_FIELD_MISSING" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션 시작
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 기존 사용자 확인
|
||||||
|
const existingUser = await client.query(
|
||||||
|
"SELECT user_id FROM user_info WHERE user_id = $1",
|
||||||
|
[userInfo.user_id]
|
||||||
|
);
|
||||||
|
const isExistingUser = existingUser.rows.length > 0;
|
||||||
|
|
||||||
|
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
|
||||||
|
let encryptedPassword = null;
|
||||||
|
if (userInfo.user_password) {
|
||||||
|
encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. user_info 저장 (UPSERT)
|
||||||
|
// mainDept가 있으면 user_info에도 메인 부서 정보 저장
|
||||||
|
const deptCode = mainDept?.dept_code || userInfo.dept_code || null;
|
||||||
|
const deptName = mainDept?.dept_name || userInfo.dept_name || null;
|
||||||
|
const positionName = mainDept?.position_name || userInfo.position_name || null;
|
||||||
|
|
||||||
|
if (isExistingUser) {
|
||||||
|
// 기존 사용자 수정
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const updateValues: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 동적으로 업데이트할 필드 구성
|
||||||
|
const fieldsToUpdate: Record<string, any> = {
|
||||||
|
user_name: userInfo.user_name,
|
||||||
|
user_name_eng: userInfo.user_name_eng,
|
||||||
|
email: userInfo.email,
|
||||||
|
tel: userInfo.tel,
|
||||||
|
cell_phone: userInfo.cell_phone,
|
||||||
|
sabun: userInfo.sabun,
|
||||||
|
user_type: userInfo.user_type,
|
||||||
|
user_type_name: userInfo.user_type_name,
|
||||||
|
status: userInfo.status || "active",
|
||||||
|
locale: userInfo.locale,
|
||||||
|
dept_code: deptCode,
|
||||||
|
dept_name: deptName,
|
||||||
|
position_code: userInfo.position_code,
|
||||||
|
position_name: positionName,
|
||||||
|
company_code: companyCode !== "*" ? companyCode : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비밀번호가 제공된 경우에만 업데이트
|
||||||
|
if (encryptedPassword) {
|
||||||
|
fieldsToUpdate.user_password = encryptedPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fieldsToUpdate)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
updateFields.push(`${key} = $${paramIndex}`);
|
||||||
|
updateValues.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length > 0) {
|
||||||
|
updateValues.push(userInfo.user_id);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
|
||||||
|
updateValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 새 사용자 등록
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO user_info (
|
||||||
|
user_id, user_name, user_name_eng, user_password,
|
||||||
|
email, tel, cell_phone, sabun,
|
||||||
|
user_type, user_type_name, status, locale,
|
||||||
|
dept_code, dept_name, position_code, position_name,
|
||||||
|
company_code, regdate
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
|
||||||
|
[
|
||||||
|
userInfo.user_id,
|
||||||
|
userInfo.user_name,
|
||||||
|
userInfo.user_name_eng || null,
|
||||||
|
encryptedPassword || null,
|
||||||
|
userInfo.email || null,
|
||||||
|
userInfo.tel || null,
|
||||||
|
userInfo.cell_phone || null,
|
||||||
|
userInfo.sabun || null,
|
||||||
|
userInfo.user_type || null,
|
||||||
|
userInfo.user_type_name || null,
|
||||||
|
userInfo.status || "active",
|
||||||
|
userInfo.locale || null,
|
||||||
|
deptCode,
|
||||||
|
deptName,
|
||||||
|
userInfo.position_code || null,
|
||||||
|
positionName,
|
||||||
|
companyCode !== "*" ? companyCode : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. user_dept 처리
|
||||||
|
if (mainDept?.dept_code || subDepts.length > 0) {
|
||||||
|
// 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용)
|
||||||
|
const existingDepts = await client.query(
|
||||||
|
"SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1",
|
||||||
|
[userInfo.user_id]
|
||||||
|
);
|
||||||
|
const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true);
|
||||||
|
|
||||||
|
// 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환
|
||||||
|
if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) {
|
||||||
|
logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", {
|
||||||
|
userId: userInfo.user_id,
|
||||||
|
oldMain: existingMainDept.dept_code,
|
||||||
|
newMain: mainDept.dept_code,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
"UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2",
|
||||||
|
[userInfo.user_id, existingMainDept.dept_code]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-3. 기존 겸직 부서 삭제 (메인 제외)
|
||||||
|
// 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false",
|
||||||
|
[userInfo.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4-4. 메인 부서 저장 (UPSERT)
|
||||||
|
if (mainDept?.dept_code) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
||||||
|
is_primary = true,
|
||||||
|
dept_name = $3,
|
||||||
|
user_name = $4,
|
||||||
|
position_name = $5,
|
||||||
|
company_code = $6,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
userInfo.user_id,
|
||||||
|
mainDept.dept_code,
|
||||||
|
mainDept.dept_name || null,
|
||||||
|
userInfo.user_name,
|
||||||
|
mainDept.position_name || null,
|
||||||
|
companyCode !== "*" ? companyCode : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4-5. 겸직 부서 저장
|
||||||
|
for (const subDept of subDepts) {
|
||||||
|
if (!subDept.dept_code) continue;
|
||||||
|
|
||||||
|
// 메인 부서와 같은 부서는 겸직으로 추가하지 않음
|
||||||
|
if (mainDept?.dept_code === subDept.dept_code) continue;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
ON CONFLICT (user_id, dept_code) DO UPDATE SET
|
||||||
|
is_primary = false,
|
||||||
|
dept_name = $3,
|
||||||
|
user_name = $4,
|
||||||
|
position_name = $5,
|
||||||
|
company_code = $6,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
userInfo.user_id,
|
||||||
|
subDept.dept_code,
|
||||||
|
subDept.dept_name || null,
|
||||||
|
userInfo.user_name,
|
||||||
|
subDept.position_name || null,
|
||||||
|
companyCode !== "*" ? companyCode : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 트랜잭션 커밋
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("사원+부서 통합 저장 완료", {
|
||||||
|
userId: userInfo.user_id,
|
||||||
|
isUpdate: isExistingUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
|
||||||
|
data: {
|
||||||
|
userId: userInfo.user_id,
|
||||||
|
isUpdate: isExistingUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
// 트랜잭션 롤백
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
|
||||||
|
logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body });
|
||||||
|
|
||||||
|
// 중복 키 에러 처리
|
||||||
|
if (error.code === "23505") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 사용자 ID입니다.",
|
||||||
|
error: { code: "DUPLICATE_USER_ID" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "사원 저장 중 오류가 발생했습니다.",
|
||||||
|
error: { code: "SAVE_ERROR", details: error.message },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/users/:userId/with-dept
|
||||||
|
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||||
|
*/
|
||||||
|
export const getUserWithDept = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
logger.info("사원+부서 조회 요청", { userId, companyCode });
|
||||||
|
|
||||||
|
// 1. user_info 조회
|
||||||
|
let userQuery = "SELECT * FROM user_info WHERE user_id = $1";
|
||||||
|
const userParams: any[] = [userId];
|
||||||
|
|
||||||
|
// 최고 관리자가 아니면 회사 필터링
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
userQuery += " AND company_code = $2";
|
||||||
|
userParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResult = await query<any>(userQuery, userParams);
|
||||||
|
|
||||||
|
if (userResult.length === 0) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
error: { code: "USER_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = userResult[0];
|
||||||
|
|
||||||
|
// 2. user_dept 조회 (메인 + 겸직)
|
||||||
|
let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC";
|
||||||
|
const deptResult = await query<any>(deptQuery, [userId]);
|
||||||
|
|
||||||
|
const mainDept = deptResult.find((d: any) => d.is_primary === true);
|
||||||
|
const subDepts = deptResult.filter((d: any) => d.is_primary === false);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
userInfo,
|
||||||
|
mainDept: mainDept || null,
|
||||||
|
subDepts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "사원 조회 중 오류가 발생했습니다.",
|
||||||
|
error: { code: "QUERY_ERROR", details: error.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,568 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 컨트롤러
|
||||||
|
* 마스터 선택 시 여러 필드 자동 입력 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
g.*,
|
||||||
|
COUNT(m.mapping_id) as mapping_count
|
||||||
|
FROM cascading_auto_fill_group g
|
||||||
|
LEFT JOIN cascading_auto_fill_mapping m
|
||||||
|
ON g.group_code = m.group_code AND g.company_code = m.company_code
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY g.group_id ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 상세 조회 (매핑 포함)
|
||||||
|
*/
|
||||||
|
export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_group
|
||||||
|
WHERE group_code = $1
|
||||||
|
`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupResult = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!groupResult) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order, mapping_id
|
||||||
|
`;
|
||||||
|
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...groupResult,
|
||||||
|
mappings: mappingResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "AF";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
mappings = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !masterTable || !masterValueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateAutoFillGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name, description,
|
||||||
|
master_table, master_value_column, master_label_column,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const groupResult = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 생성
|
||||||
|
if (mappings.length > 0) {
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 생성", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 생성되었습니다.",
|
||||||
|
data: groupResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
mappings,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 업데이트
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_auto_fill_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
master_table = COALESCE($3, master_table),
|
||||||
|
master_value_column = COALESCE($4, master_value_column),
|
||||||
|
master_label_column = COALESCE($5, master_label_column),
|
||||||
|
is_active = COALESCE($6, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $7 AND company_code = $8
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateResult = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
masterTable,
|
||||||
|
masterValueColumn,
|
||||||
|
masterLabelColumn,
|
||||||
|
isActive,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 매핑 업데이트 (전체 교체 방식)
|
||||||
|
if (mappings !== undefined) {
|
||||||
|
// 기존 매핑 삭제
|
||||||
|
await query(
|
||||||
|
`DELETE FROM cascading_auto_fill_mapping WHERE group_code = $1 AND company_code = $2`,
|
||||||
|
[groupCode, existing.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 매핑 추가
|
||||||
|
for (let i = 0; i < mappings.length; i++) {
|
||||||
|
const m = mappings[i];
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label,
|
||||||
|
is_editable, is_required, default_value, sort_order
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
m.sourceColumn,
|
||||||
|
m.targetField,
|
||||||
|
m.targetLabel || null,
|
||||||
|
m.isEditable || "Y",
|
||||||
|
m.isRequired || "N",
|
||||||
|
m.defaultValue || null,
|
||||||
|
m.sortOrder || i + 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 수정", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 수정되었습니다.",
|
||||||
|
data: updateResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_auto_fill_group WHERE group_code = $1`;
|
||||||
|
const deleteParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 그룹 삭제", { groupCode, companyCode, userId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "자동 입력 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 옵션 목록 조회
|
||||||
|
* 자동 입력 그룹의 마스터 테이블에서 선택 가능한 옵션 목록
|
||||||
|
*/
|
||||||
|
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 옵션 조회
|
||||||
|
const labelColumn = group.master_label_column || group.master_value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${group.master_value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터 (테이블에 company_code가 있는 경우)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
// company_code 컬럼 존재 여부 확인
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 마스터 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 마스터 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 입력 데이터 조회
|
||||||
|
* 마스터 값 선택 시 자동으로 입력할 데이터 조회
|
||||||
|
*/
|
||||||
|
export const getAutoFillData = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const { masterValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!masterValue) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "masterValue 파라미터가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 정보 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_auto_fill_group WHERE group_code = $1 AND is_active = 'Y'`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매핑 정보 조회
|
||||||
|
const mappingSql = `
|
||||||
|
SELECT * FROM cascading_auto_fill_mapping
|
||||||
|
WHERE group_code = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order
|
||||||
|
`;
|
||||||
|
const mappings = await query(mappingSql, [groupCode, group.company_code]);
|
||||||
|
|
||||||
|
if (mappings.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {},
|
||||||
|
mappings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마스터 테이블에서 데이터 조회
|
||||||
|
const sourceColumns = mappings.map((m: any) => m.source_column).join(", ");
|
||||||
|
let dataSql = `
|
||||||
|
SELECT ${sourceColumns}
|
||||||
|
FROM ${group.master_table}
|
||||||
|
WHERE ${group.master_value_column} = $1
|
||||||
|
`;
|
||||||
|
const dataParams: any[] = [masterValue];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[group.master_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
dataSql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
dataParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataResult = await queryOne(dataSql, dataParams);
|
||||||
|
|
||||||
|
// 결과를 target_field 기준으로 변환
|
||||||
|
const autoFillData: Record<string, any> = {};
|
||||||
|
const mappingInfo: any[] = [];
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const sourceValue = dataResult?.[mapping.source_column];
|
||||||
|
const finalValue = sourceValue !== null && sourceValue !== undefined
|
||||||
|
? sourceValue
|
||||||
|
: mapping.default_value;
|
||||||
|
|
||||||
|
autoFillData[mapping.target_field] = finalValue;
|
||||||
|
mappingInfo.push({
|
||||||
|
targetField: mapping.target_field,
|
||||||
|
targetLabel: mapping.target_label,
|
||||||
|
value: finalValue,
|
||||||
|
isEditable: mapping.is_editable === "Y",
|
||||||
|
isRequired: mapping.is_required === "Y",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: autoFillData,
|
||||||
|
mappings: mappingInfo,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자동 입력 데이터 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자동 입력 데이터 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,525 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 컨트롤러
|
||||||
|
* 특정 필드 값에 따라 드롭다운 옵션을 필터링하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getConditions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, relationCode, relationType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드 필터
|
||||||
|
if (relationCode) {
|
||||||
|
sql += ` AND relation_code = $${paramIndex++}`;
|
||||||
|
params.push(relationCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 유형 필터 (RELATION / HIERARCHY)
|
||||||
|
if (relationType) {
|
||||||
|
sql += ` AND relation_type = $${paramIndex++}`;
|
||||||
|
params.push(relationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY relation_code, priority, condition_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
|
||||||
|
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getConditionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const params: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 상세 조회", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
relationType = "RELATION",
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator = "EQ",
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority = 0,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_type, relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, priority,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
relationType,
|
||||||
|
relationCode,
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_condition SET
|
||||||
|
condition_name = COALESCE($1, condition_name),
|
||||||
|
condition_field = COALESCE($2, condition_field),
|
||||||
|
condition_operator = COALESCE($3, condition_operator),
|
||||||
|
condition_value = COALESCE($4, condition_value),
|
||||||
|
filter_column = COALESCE($5, filter_column),
|
||||||
|
filter_values = COALESCE($6, filter_values),
|
||||||
|
priority = COALESCE($7, priority),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE condition_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
conditionName,
|
||||||
|
conditionField,
|
||||||
|
conditionOperator,
|
||||||
|
conditionValue,
|
||||||
|
filterColumn,
|
||||||
|
filterValues,
|
||||||
|
priority,
|
||||||
|
isActive,
|
||||||
|
Number(conditionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 수정", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCondition = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { conditionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_condition WHERE condition_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(conditionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING condition_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("조건부 연쇄 규칙 삭제", { conditionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "조건부 연쇄 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 연쇄 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 연쇄 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 필터링된 옵션 조회
|
||||||
|
* 특정 관계 코드에 대해 조건 필드 값에 따라 필터링된 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getFilteredOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { relationCode } = req.params;
|
||||||
|
const { conditionFieldValue, parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 1. 기본 연쇄 관계 정보 조회
|
||||||
|
let relationSql = `SELECT * FROM cascading_relation WHERE relation_code = $1 AND is_active = 'Y'`;
|
||||||
|
const relationParams: any[] = [relationCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationSql += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = await queryOne(relationSql, relationParams);
|
||||||
|
|
||||||
|
if (!relation) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 해당 관계에 적용되는 조건 규칙 조회
|
||||||
|
let conditionSql = `
|
||||||
|
SELECT * FROM cascading_condition
|
||||||
|
WHERE relation_code = $1 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const conditionParams: any[] = [relationCode];
|
||||||
|
let conditionParamIndex = 2;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
conditionSql += ` AND company_code = $${conditionParamIndex++}`;
|
||||||
|
conditionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
conditionSql += ` ORDER BY priority DESC`;
|
||||||
|
|
||||||
|
const conditions = await query(conditionSql, conditionParams);
|
||||||
|
|
||||||
|
// 3. 조건에 맞는 규칙 찾기
|
||||||
|
let matchedCondition: any = null;
|
||||||
|
|
||||||
|
if (conditionFieldValue) {
|
||||||
|
for (const cond of conditions) {
|
||||||
|
const isMatch = evaluateCondition(
|
||||||
|
conditionFieldValue as string,
|
||||||
|
cond.condition_operator,
|
||||||
|
cond.condition_value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matchedCondition = cond;
|
||||||
|
break; // 우선순위가 높은 첫 번째 매칭 규칙 사용
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 옵션 조회 쿼리 생성
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (기본 연쇄)
|
||||||
|
if (parentValue) {
|
||||||
|
optionsSql += ` AND ${relation.child_filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건부 필터 적용
|
||||||
|
if (matchedCondition) {
|
||||||
|
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
|
||||||
|
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
|
||||||
|
optionsParams.push(...filterValues);
|
||||||
|
optionsParamIndex += filterValues.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (relation.child_order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${relation.child_label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("조건부 필터링 옵션 조회", {
|
||||||
|
relationCode,
|
||||||
|
conditionFieldValue,
|
||||||
|
parentValue,
|
||||||
|
matchedCondition: matchedCondition?.condition_name,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
appliedCondition: matchedCondition
|
||||||
|
? {
|
||||||
|
conditionId: matchedCondition.condition_id,
|
||||||
|
conditionName: matchedCondition.condition_name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("조건부 필터링 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "조건부 필터링 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건 평가 함수
|
||||||
|
*/
|
||||||
|
function evaluateCondition(
|
||||||
|
actualValue: string,
|
||||||
|
operator: string,
|
||||||
|
expectedValue: string
|
||||||
|
): boolean {
|
||||||
|
const actual = actualValue.toLowerCase().trim();
|
||||||
|
const expected = expectedValue.toLowerCase().trim();
|
||||||
|
|
||||||
|
switch (operator.toUpperCase()) {
|
||||||
|
case "EQ":
|
||||||
|
case "=":
|
||||||
|
case "EQUALS":
|
||||||
|
return actual === expected;
|
||||||
|
|
||||||
|
case "NEQ":
|
||||||
|
case "!=":
|
||||||
|
case "<>":
|
||||||
|
case "NOT_EQUALS":
|
||||||
|
return actual !== expected;
|
||||||
|
|
||||||
|
case "CONTAINS":
|
||||||
|
case "LIKE":
|
||||||
|
return actual.includes(expected);
|
||||||
|
|
||||||
|
case "NOT_CONTAINS":
|
||||||
|
case "NOT_LIKE":
|
||||||
|
return !actual.includes(expected);
|
||||||
|
|
||||||
|
case "STARTS_WITH":
|
||||||
|
return actual.startsWith(expected);
|
||||||
|
|
||||||
|
case "ENDS_WITH":
|
||||||
|
return actual.endsWith(expected);
|
||||||
|
|
||||||
|
case "IN":
|
||||||
|
const inValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return inValues.includes(actual);
|
||||||
|
|
||||||
|
case "NOT_IN":
|
||||||
|
const notInValues = expected.split(",").map((v) => v.trim());
|
||||||
|
return !notInValues.includes(actual);
|
||||||
|
|
||||||
|
case "GT":
|
||||||
|
case ">":
|
||||||
|
return parseFloat(actual) > parseFloat(expected);
|
||||||
|
|
||||||
|
case "GTE":
|
||||||
|
case ">=":
|
||||||
|
return parseFloat(actual) >= parseFloat(expected);
|
||||||
|
|
||||||
|
case "LT":
|
||||||
|
case "<":
|
||||||
|
return parseFloat(actual) < parseFloat(expected);
|
||||||
|
|
||||||
|
case "LTE":
|
||||||
|
case "<=":
|
||||||
|
return parseFloat(actual) <= parseFloat(expected);
|
||||||
|
|
||||||
|
case "IS_NULL":
|
||||||
|
case "NULL":
|
||||||
|
return actual === "" || actual === "null" || actual === "undefined";
|
||||||
|
|
||||||
|
case "IS_NOT_NULL":
|
||||||
|
case "NOT_NULL":
|
||||||
|
return actual !== "" && actual !== "null" && actual !== "undefined";
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn(`알 수 없는 연산자: ${operator}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,752 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 컨트롤러
|
||||||
|
* 국가 > 도시 > 구/군 같은 다단계 연쇄 드롭다운 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 목록 조회
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroups = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive, hierarchyType } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT g.*,
|
||||||
|
(SELECT COUNT(*) FROM cascading_hierarchy_level l WHERE l.group_code = g.group_code AND l.company_code = g.company_code) as level_count
|
||||||
|
FROM cascading_hierarchy_group g
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND g.company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND g.is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hierarchyType) {
|
||||||
|
sql += ` AND g.hierarchy_type = $${paramIndex++}`;
|
||||||
|
params.push(hierarchyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY g.group_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 상세 조회 (레벨 포함)
|
||||||
|
*/
|
||||||
|
export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 그룹 조회
|
||||||
|
let groupSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
groupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await queryOne(groupSql, groupParams);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레벨 조회
|
||||||
|
let levelSql = `SELECT * FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
levelSql += ` ORDER BY level_order`;
|
||||||
|
|
||||||
|
const levels = await query(levelSql, levelParams);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 상세 조회", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...group,
|
||||||
|
levels: levels,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "HG";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 생성
|
||||||
|
*/
|
||||||
|
export const createHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
hierarchyType = "MULTI_TABLE",
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels = "Y",
|
||||||
|
// Self-reference 설정
|
||||||
|
selfRefTable,
|
||||||
|
selfRefIdColumn,
|
||||||
|
selfRefParentColumn,
|
||||||
|
selfRefValueColumn,
|
||||||
|
selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn,
|
||||||
|
// BOM 설정
|
||||||
|
bomTable,
|
||||||
|
bomParentColumn,
|
||||||
|
bomChildColumn,
|
||||||
|
bomItemTable,
|
||||||
|
bomItemIdColumn,
|
||||||
|
bomItemLabelColumn,
|
||||||
|
bomQtyColumn,
|
||||||
|
bomLevelColumn,
|
||||||
|
// 메시지
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
// 레벨 (MULTI_TABLE 타입인 경우)
|
||||||
|
levels = [],
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!groupName || !hierarchyType) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (groupName, hierarchyType)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 코드 자동 생성
|
||||||
|
const groupCode = await generateHierarchyGroupCode(companyCode);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
const insertGroupSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, description, hierarchy_type,
|
||||||
|
max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column, self_ref_order_column,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column, bom_qty_column, bom_level_column,
|
||||||
|
empty_message, no_options_message, loading_message,
|
||||||
|
company_code, is_active, created_by, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, 'Y', $26, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const group = await queryOne(insertGroupSql, [
|
||||||
|
groupCode,
|
||||||
|
groupName,
|
||||||
|
description || null,
|
||||||
|
hierarchyType,
|
||||||
|
maxLevels || null,
|
||||||
|
isFixedLevels,
|
||||||
|
selfRefTable || null,
|
||||||
|
selfRefIdColumn || null,
|
||||||
|
selfRefParentColumn || null,
|
||||||
|
selfRefValueColumn || null,
|
||||||
|
selfRefLabelColumn || null,
|
||||||
|
selfRefLevelColumn || null,
|
||||||
|
selfRefOrderColumn || null,
|
||||||
|
bomTable || null,
|
||||||
|
bomParentColumn || null,
|
||||||
|
bomChildColumn || null,
|
||||||
|
bomItemTable || null,
|
||||||
|
bomItemIdColumn || null,
|
||||||
|
bomItemLabelColumn || null,
|
||||||
|
bomQtyColumn || null,
|
||||||
|
bomLevelColumn || null,
|
||||||
|
emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage || "로딩 중...",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 레벨 생성 (MULTI_TABLE 타입인 경우)
|
||||||
|
if (hierarchyType === "MULTI_TABLE" && levels.length > 0) {
|
||||||
|
for (const level of levels) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)`,
|
||||||
|
[
|
||||||
|
groupCode,
|
||||||
|
companyCode,
|
||||||
|
level.levelOrder,
|
||||||
|
level.levelName,
|
||||||
|
level.levelCode || null,
|
||||||
|
level.tableName,
|
||||||
|
level.valueColumn,
|
||||||
|
level.labelColumn,
|
||||||
|
level.parentKeyColumn || null,
|
||||||
|
level.filterColumn || null,
|
||||||
|
level.filterValue || null,
|
||||||
|
level.orderColumn || null,
|
||||||
|
level.orderDirection || "ASC",
|
||||||
|
level.placeholder || `${level.levelName} 선택`,
|
||||||
|
level.isRequired || "Y",
|
||||||
|
level.isSearchable || "N",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 생성", { groupCode, hierarchyType, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 생성되었습니다.",
|
||||||
|
data: group,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 수정
|
||||||
|
*/
|
||||||
|
export const updateHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const {
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 그룹 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const checkParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_group SET
|
||||||
|
group_name = COALESCE($1, group_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
max_levels = COALESCE($3, max_levels),
|
||||||
|
is_fixed_levels = COALESCE($4, is_fixed_levels),
|
||||||
|
empty_message = COALESCE($5, empty_message),
|
||||||
|
no_options_message = COALESCE($6, no_options_message),
|
||||||
|
loading_message = COALESCE($7, loading_message),
|
||||||
|
is_active = COALESCE($8, is_active),
|
||||||
|
updated_by = $9,
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE group_code = $10 AND company_code = $11
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
groupName,
|
||||||
|
description,
|
||||||
|
maxLevels,
|
||||||
|
isFixedLevels,
|
||||||
|
emptyMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
isActive,
|
||||||
|
userId,
|
||||||
|
groupCode,
|
||||||
|
existing.company_code,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 그룹 수정", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계층 그룹 삭제
|
||||||
|
*/
|
||||||
|
export const deleteHierarchyGroup = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 먼저 삭제
|
||||||
|
let deleteLevelsSql = `DELETE FROM cascading_hierarchy_level WHERE group_code = $1`;
|
||||||
|
const levelParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteLevelsSql += ` AND company_code = $2`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(deleteLevelsSql, levelParams);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
let deleteGroupSql = `DELETE FROM cascading_hierarchy_group WHERE group_code = $1`;
|
||||||
|
const groupParams: any[] = [groupCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteGroupSql += ` AND company_code = $2`;
|
||||||
|
groupParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteGroupSql += ` RETURNING group_code`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteGroupSql, groupParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 그룹 삭제", { groupCode, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "계층 그룹이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 그룹 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 추가
|
||||||
|
*/
|
||||||
|
export const addLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection = "ASC",
|
||||||
|
placeholder,
|
||||||
|
isRequired = "Y",
|
||||||
|
isSearchable = "N",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 그룹 존재 확인
|
||||||
|
const groupCheck = await queryOne(
|
||||||
|
`SELECT * FROM cascading_hierarchy_group WHERE group_code = $1 AND (company_code = $2 OR $2 = '*')`,
|
||||||
|
[groupCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!groupCheck) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "계층 그룹을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_hierarchy_level (
|
||||||
|
group_code, company_code, level_order, level_name, level_code,
|
||||||
|
table_name, value_column, label_column, parent_key_column,
|
||||||
|
filter_column, filter_value, order_column, order_direction,
|
||||||
|
placeholder, is_required, is_searchable, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
groupCode,
|
||||||
|
groupCheck.company_code,
|
||||||
|
levelOrder,
|
||||||
|
levelName,
|
||||||
|
levelCode || null,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn || null,
|
||||||
|
filterColumn || null,
|
||||||
|
filterValue || null,
|
||||||
|
orderColumn || null,
|
||||||
|
orderDirection,
|
||||||
|
placeholder || `${levelName} 선택`,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 추가", { groupCode, levelOrder, levelName });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 추가되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 추가 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 추가에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 수정
|
||||||
|
*/
|
||||||
|
export const updateLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
let checkSql = `SELECT * FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_hierarchy_level SET
|
||||||
|
level_name = COALESCE($1, level_name),
|
||||||
|
table_name = COALESCE($2, table_name),
|
||||||
|
value_column = COALESCE($3, value_column),
|
||||||
|
label_column = COALESCE($4, label_column),
|
||||||
|
parent_key_column = COALESCE($5, parent_key_column),
|
||||||
|
filter_column = COALESCE($6, filter_column),
|
||||||
|
filter_value = COALESCE($7, filter_value),
|
||||||
|
order_column = COALESCE($8, order_column),
|
||||||
|
order_direction = COALESCE($9, order_direction),
|
||||||
|
placeholder = COALESCE($10, placeholder),
|
||||||
|
is_required = COALESCE($11, is_required),
|
||||||
|
is_searchable = COALESCE($12, is_searchable),
|
||||||
|
is_active = COALESCE($13, is_active),
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE level_id = $14
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
levelName,
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
parentKeyColumn,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
orderColumn,
|
||||||
|
orderDirection,
|
||||||
|
placeholder,
|
||||||
|
isRequired,
|
||||||
|
isSearchable,
|
||||||
|
isActive,
|
||||||
|
Number(levelId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 수정", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레벨 삭제
|
||||||
|
*/
|
||||||
|
export const deleteLevel = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { levelId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_hierarchy_level WHERE level_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(levelId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING level_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("계층 레벨 삭제", { levelId });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레벨이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레벨의 옵션 조회
|
||||||
|
*/
|
||||||
|
export const getLevelOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { groupCode, levelOrder } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 레벨 정보 조회
|
||||||
|
let levelSql = `
|
||||||
|
SELECT l.*, g.hierarchy_type
|
||||||
|
FROM cascading_hierarchy_level l
|
||||||
|
JOIN cascading_hierarchy_group g ON l.group_code = g.group_code AND l.company_code = g.company_code
|
||||||
|
WHERE l.group_code = $1 AND l.level_order = $2 AND l.is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const levelParams: any[] = [groupCode, Number(levelOrder)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
levelSql += ` AND l.company_code = $3`;
|
||||||
|
levelParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = await queryOne(levelSql, levelParams);
|
||||||
|
|
||||||
|
if (!level) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레벨을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${level.value_column} as value,
|
||||||
|
${level.label_column} as label
|
||||||
|
FROM ${level.table_name}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 부모 값 필터 (레벨 2 이상)
|
||||||
|
if (level.parent_key_column && parentValue) {
|
||||||
|
optionsSql += ` AND ${level.parent_key_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(parentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고정 필터
|
||||||
|
if (level.filter_column && level.filter_value) {
|
||||||
|
optionsSql += ` AND ${level.filter_column} = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(level.filter_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[level.table_name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (level.order_column) {
|
||||||
|
optionsSql += ` ORDER BY ${level.order_column} ${level.order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsSql += ` ORDER BY ${level.label_column}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("계층 레벨 옵션 조회", {
|
||||||
|
groupCode,
|
||||||
|
levelOrder,
|
||||||
|
parentValue,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
levelInfo: {
|
||||||
|
levelId: level.level_id,
|
||||||
|
levelName: level.level_name,
|
||||||
|
placeholder: level.placeholder,
|
||||||
|
isRequired: level.is_required,
|
||||||
|
isSearchable: level.is_searchable,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("계층 레벨 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,505 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 컨트롤러
|
||||||
|
* 두 필드가 같은 값을 선택할 수 없도록 제한하는 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 CRUD
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 목록 조회
|
||||||
|
*/
|
||||||
|
export const getExclusions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM cascading_mutual_exclusion
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${paramIndex++}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터
|
||||||
|
if (isActive) {
|
||||||
|
sql += ` AND is_active = $${paramIndex++}`;
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY exclusion_name`;
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 상세 조회
|
||||||
|
*/
|
||||||
|
export const getExclusionDetail = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let sql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const params: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 상세 조회", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 상세 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 상세 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배제 코드 자동 생성 함수
|
||||||
|
*/
|
||||||
|
const generateExclusionCode = async (companyCode: string): Promise<string> => {
|
||||||
|
const prefix = "EX";
|
||||||
|
const result = await queryOne(
|
||||||
|
`SELECT COUNT(*) as cnt FROM cascading_mutual_exclusion WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.cnt || "0", 10) + 1;
|
||||||
|
const timestamp = Date.now().toString(36).toUpperCase().slice(-4);
|
||||||
|
return `${prefix}_${timestamp}_${count.toString().padStart(3, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 생성
|
||||||
|
*/
|
||||||
|
export const createExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames, // 콤마로 구분된 필드명 (예: "source_warehouse,target_warehouse")
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType = "SAME_VALUE",
|
||||||
|
errorMessage = "동일한 값을 선택할 수 없습니다",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배제 코드 자동 생성
|
||||||
|
const exclusionCode = await generateExclusionCode(companyCode);
|
||||||
|
|
||||||
|
// 중복 체크 (생략 - 자동 생성이므로 중복 불가)
|
||||||
|
const existingCheck = await queryOne(
|
||||||
|
`SELECT exclusion_id FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND company_code = $2`,
|
||||||
|
[exclusionCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 배제 코드입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
exclusion_type, error_message,
|
||||||
|
company_code, is_active, created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(insertSql, [
|
||||||
|
exclusionCode,
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn || null,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 생성", { exclusionCode, companyCode });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 생성되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 생성 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 수정
|
||||||
|
*/
|
||||||
|
export const updateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const {
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 기존 규칙 확인
|
||||||
|
let checkSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const checkParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkSql += ` AND company_code = $2`;
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await queryOne(checkSql, checkParams);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSql = `
|
||||||
|
UPDATE cascading_mutual_exclusion SET
|
||||||
|
exclusion_name = COALESCE($1, exclusion_name),
|
||||||
|
field_names = COALESCE($2, field_names),
|
||||||
|
source_table = COALESCE($3, source_table),
|
||||||
|
value_column = COALESCE($4, value_column),
|
||||||
|
label_column = COALESCE($5, label_column),
|
||||||
|
exclusion_type = COALESCE($6, exclusion_type),
|
||||||
|
error_message = COALESCE($7, error_message),
|
||||||
|
is_active = COALESCE($8, is_active)
|
||||||
|
WHERE exclusion_id = $9
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryOne(updateSql, [
|
||||||
|
exclusionName,
|
||||||
|
fieldNames,
|
||||||
|
sourceTable,
|
||||||
|
valueColumn,
|
||||||
|
labelColumn,
|
||||||
|
exclusionType,
|
||||||
|
errorMessage,
|
||||||
|
isActive,
|
||||||
|
Number(exclusionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 수정", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 수정되었습니다.",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 수정 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 규칙 삭제
|
||||||
|
*/
|
||||||
|
export const deleteExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionId } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let deleteSql = `DELETE FROM cascading_mutual_exclusion WHERE exclusion_id = $1`;
|
||||||
|
const deleteParams: any[] = [Number(exclusionId)];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
deleteSql += ` AND company_code = $2`;
|
||||||
|
deleteParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSql += ` RETURNING exclusion_id`;
|
||||||
|
|
||||||
|
const result = await queryOne(deleteSql, deleteParams);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 규칙 삭제", { exclusionId, companyCode });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "상호 배제 규칙이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 규칙 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상호 배제 검증
|
||||||
|
* 선택하려는 값이 다른 필드와 충돌하는지 확인
|
||||||
|
*/
|
||||||
|
export const validateExclusion = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드명 파싱
|
||||||
|
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
|
||||||
|
|
||||||
|
// 필드 값 수집
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (fieldValues[field]) {
|
||||||
|
values.push(fieldValues[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
let isValid = true;
|
||||||
|
let errorMessage = null;
|
||||||
|
let conflictingFields: string[] = [];
|
||||||
|
|
||||||
|
if (exclusion.exclusion_type === "SAME_VALUE") {
|
||||||
|
// 같은 값이 있는지 확인
|
||||||
|
const uniqueValues = new Set(values);
|
||||||
|
if (uniqueValues.size !== values.length) {
|
||||||
|
isValid = false;
|
||||||
|
errorMessage = exclusion.error_message;
|
||||||
|
|
||||||
|
// 충돌하는 필드 찾기
|
||||||
|
const valueCounts: Record<string, string[]> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const val = fieldValues[field];
|
||||||
|
if (val) {
|
||||||
|
if (!valueCounts[val]) {
|
||||||
|
valueCounts[val] = [];
|
||||||
|
}
|
||||||
|
valueCounts[val].push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, fieldList] of Object.entries(valueCounts)) {
|
||||||
|
if (fieldList.length > 1) {
|
||||||
|
conflictingFields = fieldList;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("상호 배제 검증", {
|
||||||
|
exclusionCode,
|
||||||
|
isValid,
|
||||||
|
fieldValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
isValid,
|
||||||
|
errorMessage: isValid ? null : errorMessage,
|
||||||
|
conflictingFields,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 검증 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 검증에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드에 대한 배제 옵션 조회
|
||||||
|
* 다른 필드에서 이미 선택한 값을 제외한 옵션 반환
|
||||||
|
*/
|
||||||
|
export const getExcludedOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { exclusionCode } = req.params;
|
||||||
|
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 배제 규칙 조회
|
||||||
|
let exclusionSql = `SELECT * FROM cascading_mutual_exclusion WHERE exclusion_code = $1 AND is_active = 'Y'`;
|
||||||
|
const exclusionParams: any[] = [exclusionCode];
|
||||||
|
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
exclusionSql += ` AND company_code = $2`;
|
||||||
|
exclusionParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusion = await queryOne(exclusionSql, exclusionParams);
|
||||||
|
|
||||||
|
if (!exclusion) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 규칙을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 조회
|
||||||
|
const labelColumn = exclusion.label_column || exclusion.value_column;
|
||||||
|
let optionsSql = `
|
||||||
|
SELECT
|
||||||
|
${exclusion.value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${exclusion.source_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
let optionsParamIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
const columnCheck = await queryOne(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[exclusion.source_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnCheck) {
|
||||||
|
optionsSql += ` AND company_code = $${optionsParamIndex++}`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 선택된 값 제외
|
||||||
|
if (selectedValues) {
|
||||||
|
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
|
||||||
|
if (excludeValues.length > 0) {
|
||||||
|
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
|
||||||
|
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
|
||||||
|
optionsParams.push(...excludeValues);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsSql += ` ORDER BY ${labelColumn}`;
|
||||||
|
|
||||||
|
const optionsResult = await query(optionsSql, optionsParams);
|
||||||
|
|
||||||
|
logger.info("상호 배제 옵션 조회", {
|
||||||
|
exclusionCode,
|
||||||
|
currentField,
|
||||||
|
excludedCount: (selectedValues as string)?.split(",").length || 0,
|
||||||
|
optionCount: optionsResult.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("상호 배제 옵션 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "상호 배제 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,750 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 목록 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelations = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const { isActive } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active,
|
||||||
|
created_by,
|
||||||
|
created_date,
|
||||||
|
updated_by,
|
||||||
|
updated_date
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링
|
||||||
|
// - 최고 관리자(company_code = "*"): 모든 데이터 조회 가능
|
||||||
|
// - 일반 회사: 자기 회사 데이터만 조회 (공통 데이터는 조회 불가)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $${paramIndex}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 상태 필터링
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
query += ` AND is_active = $${paramIndex}`;
|
||||||
|
params.push(isActive);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY relation_name ASC`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 목록 조회", {
|
||||||
|
companyCode,
|
||||||
|
count: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 목록 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 상세 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationById = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active,
|
||||||
|
created_by,
|
||||||
|
created_date,
|
||||||
|
updated_by,
|
||||||
|
updated_date
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [id];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 상세 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 코드로 조회
|
||||||
|
*/
|
||||||
|
export const getCascadingRelationByCode = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
relation_id,
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [code];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
query += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
query += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 코드 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 생성
|
||||||
|
*/
|
||||||
|
export const createCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
const {
|
||||||
|
relationCode,
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (
|
||||||
|
!relationCode ||
|
||||||
|
!relationName ||
|
||||||
|
!parentTable ||
|
||||||
|
!parentValueColumn ||
|
||||||
|
!childTable ||
|
||||||
|
!childFilterColumn ||
|
||||||
|
!childValueColumn ||
|
||||||
|
!childLabelColumn
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 코드 체크
|
||||||
|
const duplicateCheck = await pool.query(
|
||||||
|
`SELECT relation_id FROM cascading_relation
|
||||||
|
WHERE relation_code = $1 AND company_code = $2`,
|
||||||
|
[relationCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 관계 코드입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO cascading_relation (
|
||||||
|
relation_code,
|
||||||
|
relation_name,
|
||||||
|
description,
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column,
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction,
|
||||||
|
empty_parent_message,
|
||||||
|
no_options_message,
|
||||||
|
loading_message,
|
||||||
|
clear_on_parent_change,
|
||||||
|
company_code,
|
||||||
|
is_active,
|
||||||
|
created_by,
|
||||||
|
created_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
relationCode,
|
||||||
|
relationName,
|
||||||
|
description || null,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn || null,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn || null,
|
||||||
|
childOrderDirection || "ASC",
|
||||||
|
emptyParentMessage || "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage || "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage || "로딩 중...",
|
||||||
|
clearOnParentChange !== false ? "Y" : "N",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 생성", {
|
||||||
|
relationId: result.rows[0].relation_id,
|
||||||
|
relationCode,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
message: "연쇄 관계가 생성되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 생성에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 수정
|
||||||
|
*/
|
||||||
|
export const updateCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
const {
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange,
|
||||||
|
isActive,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
|
const existingCheck = await pool.query(
|
||||||
|
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 회사의 데이터는 수정 불가 (최고 관리자 제외)
|
||||||
|
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||||
|
if (
|
||||||
|
companyCode !== "*" &&
|
||||||
|
existingCompanyCode !== companyCode &&
|
||||||
|
existingCompanyCode !== "*"
|
||||||
|
) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE cascading_relation SET
|
||||||
|
relation_name = COALESCE($1, relation_name),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
parent_table = COALESCE($3, parent_table),
|
||||||
|
parent_value_column = COALESCE($4, parent_value_column),
|
||||||
|
parent_label_column = COALESCE($5, parent_label_column),
|
||||||
|
child_table = COALESCE($6, child_table),
|
||||||
|
child_filter_column = COALESCE($7, child_filter_column),
|
||||||
|
child_value_column = COALESCE($8, child_value_column),
|
||||||
|
child_label_column = COALESCE($9, child_label_column),
|
||||||
|
child_order_column = COALESCE($10, child_order_column),
|
||||||
|
child_order_direction = COALESCE($11, child_order_direction),
|
||||||
|
empty_parent_message = COALESCE($12, empty_parent_message),
|
||||||
|
no_options_message = COALESCE($13, no_options_message),
|
||||||
|
loading_message = COALESCE($14, loading_message),
|
||||||
|
clear_on_parent_change = COALESCE($15, clear_on_parent_change),
|
||||||
|
is_active = COALESCE($16, is_active),
|
||||||
|
updated_by = $17,
|
||||||
|
updated_date = CURRENT_TIMESTAMP
|
||||||
|
WHERE relation_id = $18
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [
|
||||||
|
relationName,
|
||||||
|
description,
|
||||||
|
parentTable,
|
||||||
|
parentValueColumn,
|
||||||
|
parentLabelColumn,
|
||||||
|
childTable,
|
||||||
|
childFilterColumn,
|
||||||
|
childValueColumn,
|
||||||
|
childLabelColumn,
|
||||||
|
childOrderColumn,
|
||||||
|
childOrderDirection,
|
||||||
|
emptyParentMessage,
|
||||||
|
noOptionsMessage,
|
||||||
|
loadingMessage,
|
||||||
|
clearOnParentChange !== undefined
|
||||||
|
? clearOnParentChange
|
||||||
|
? "Y"
|
||||||
|
: "N"
|
||||||
|
: null,
|
||||||
|
isActive !== undefined ? (isActive ? "Y" : "N") : null,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 수정", {
|
||||||
|
relationId: id,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0],
|
||||||
|
message: "연쇄 관계가 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 수정에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계 삭제
|
||||||
|
*/
|
||||||
|
export const deleteCascadingRelation = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
|
||||||
|
// 권한 체크
|
||||||
|
const existingCheck = await pool.query(
|
||||||
|
`SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCheck.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외)
|
||||||
|
const existingCompanyCode = existingCheck.rows[0].company_code;
|
||||||
|
if (
|
||||||
|
companyCode !== "*" &&
|
||||||
|
existingCompanyCode !== companyCode &&
|
||||||
|
existingCompanyCode !== "*"
|
||||||
|
) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "삭제 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소프트 삭제 (is_active = 'N')
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("연쇄 관계 삭제", {
|
||||||
|
relationId: id,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "연쇄 관계가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 관계 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계 삭제에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||||
|
* parent_table에서 전체 옵션을 조회합니다.
|
||||||
|
*/
|
||||||
|
export const getParentOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 관계 정보 조회
|
||||||
|
let relationQuery = `
|
||||||
|
SELECT
|
||||||
|
parent_table,
|
||||||
|
parent_value_column,
|
||||||
|
parent_label_column
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const relationParams: any[] = [code];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
relationQuery += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const relationResult = await pool.query(relationQuery, relationParams);
|
||||||
|
|
||||||
|
if (relationResult.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
|
// 라벨 컬럼이 없으면 값 컬럼 사용
|
||||||
|
const labelColumn =
|
||||||
|
relation.parent_label_column || relation.parent_value_column;
|
||||||
|
|
||||||
|
// 부모 옵션 조회
|
||||||
|
let optionsQuery = `
|
||||||
|
SELECT
|
||||||
|
${relation.parent_value_column} as value,
|
||||||
|
${labelColumn} as label
|
||||||
|
FROM ${relation.parent_table}
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
const tableInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.parent_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionsParams: any[] = [];
|
||||||
|
|
||||||
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
|
if (
|
||||||
|
tableInfoResult.rowCount &&
|
||||||
|
tableInfoResult.rowCount > 0 &&
|
||||||
|
companyCode !== "*"
|
||||||
|
) {
|
||||||
|
optionsQuery += ` AND company_code = $1`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status 컬럼이 있으면 활성 상태만 조회
|
||||||
|
const statusInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'status'`,
|
||||||
|
[relation.parent_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) {
|
||||||
|
optionsQuery += ` AND (status IS NULL OR status != 'N')`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
optionsQuery += ` ORDER BY ${labelColumn} ASC`;
|
||||||
|
|
||||||
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
|
logger.info("부모 옵션 조회", {
|
||||||
|
relationCode: code,
|
||||||
|
parentTable: relation.parent_table,
|
||||||
|
optionsCount: optionsResult.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("부모 옵션 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "부모 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
|
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||||
|
*/
|
||||||
|
export const getCascadingOptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.params;
|
||||||
|
const { parentValue } = req.query;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!parentValue) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
message: "부모 값이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 정보 조회
|
||||||
|
let relationQuery = `
|
||||||
|
SELECT
|
||||||
|
child_table,
|
||||||
|
child_filter_column,
|
||||||
|
child_value_column,
|
||||||
|
child_label_column,
|
||||||
|
child_order_column,
|
||||||
|
child_order_direction
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE relation_code = $1
|
||||||
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const relationParams: any[] = [code];
|
||||||
|
|
||||||
|
// 멀티테넌시 필터링 (company_code = "*"는 최고 관리자 전용)
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
relationQuery += ` AND company_code = $2`;
|
||||||
|
relationParams.push(companyCode);
|
||||||
|
}
|
||||||
|
relationQuery += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const relationResult = await pool.query(relationQuery, relationParams);
|
||||||
|
|
||||||
|
if (relationResult.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 관계를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
|
// 자식 옵션 조회
|
||||||
|
let optionsQuery = `
|
||||||
|
SELECT
|
||||||
|
${relation.child_value_column} as value,
|
||||||
|
${relation.child_label_column} as label
|
||||||
|
FROM ${relation.child_table}
|
||||||
|
WHERE ${relation.child_filter_column} = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
const tableInfoResult = await pool.query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[relation.child_table]
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionsParams: any[] = [parentValue];
|
||||||
|
|
||||||
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
|
if (
|
||||||
|
tableInfoResult.rowCount &&
|
||||||
|
tableInfoResult.rowCount > 0 &&
|
||||||
|
companyCode !== "*"
|
||||||
|
) {
|
||||||
|
optionsQuery += ` AND company_code = $2`;
|
||||||
|
optionsParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
if (relation.child_order_column) {
|
||||||
|
optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`;
|
||||||
|
} else {
|
||||||
|
optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
|
logger.info("연쇄 옵션 조회", {
|
||||||
|
relationCode: code,
|
||||||
|
parentValue,
|
||||||
|
optionsCount: optionsResult.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: optionsResult.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("연쇄 옵션 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "연쇄 옵션 조회에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -427,7 +427,8 @@ export const updateFieldValue = async (
|
||||||
): Promise<Response | void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
const { tableName, keyField, keyValue, updateField, updateValue } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
console.log("🔄 [updateFieldValue] 요청:", {
|
console.log("🔄 [updateFieldValue] 요청:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -440,16 +441,27 @@ export const updateFieldValue = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증
|
||||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
if (
|
||||||
|
!tableName ||
|
||||||
|
!keyField ||
|
||||||
|
keyValue === undefined ||
|
||||||
|
!updateField ||
|
||||||
|
updateValue === undefined
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
if (
|
||||||
|
!validNamePattern.test(tableName) ||
|
||||||
|
!validNamePattern.test(keyField) ||
|
||||||
|
!validNamePattern.test(updateField)
|
||||||
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||||
|
|
@ -492,7 +504,7 @@ export const saveLocationHistory = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<Response | void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId: loginUserId } = req.user as any;
|
||||||
const {
|
const {
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
|
@ -508,10 +520,17 @@ export const saveLocationHistory = async (
|
||||||
destinationName,
|
destinationName,
|
||||||
recordedAt,
|
recordedAt,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
|
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||||
|
// 없으면 로그인한 사용자의 userId 사용
|
||||||
|
const userId = requestUserId || loginUserId;
|
||||||
|
|
||||||
console.log("📍 [saveLocationHistory] 요청:", {
|
console.log("📍 [saveLocationHistory] 요청:", {
|
||||||
userId,
|
userId,
|
||||||
|
requestUserId,
|
||||||
|
loginUserId,
|
||||||
companyCode,
|
companyCode,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,64 @@ export const uploadFiles = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
|
||||||
|
|
||||||
|
// 🔍 디버깅: 레코드 모드 조건 확인
|
||||||
|
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
|
||||||
|
isRecordMode,
|
||||||
|
linkedTable,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
finalTargetObjid,
|
||||||
|
"req.body.isRecordMode": req.body.isRecordMode,
|
||||||
|
"req.body.linkedTable": req.body.linkedTable,
|
||||||
|
"req.body.recordId": req.body.recordId,
|
||||||
|
"req.body.columnName": req.body.columnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRecordMode && linkedTable && recordId && columnName) {
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 모든 첨부파일 조회
|
||||||
|
const allFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[finalTargetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = allFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${linkedTable}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName: linkedTable,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
fileCount: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${files.length}개 파일 업로드 완료`,
|
message: `${files.length}개 파일 업로드 완료`,
|
||||||
|
|
@ -405,6 +463,56 @@ export const deleteFile = async (
|
||||||
["DELETED", parseInt(objid)]
|
["DELETED", parseInt(objid)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
|
||||||
|
const targetObjid = fileRecord.target_objid;
|
||||||
|
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
|
||||||
|
// targetObjid 파싱: tableName:recordId:columnName 형식
|
||||||
|
const parts = targetObjid.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const [tableName, recordId, columnName] = parts;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 해당 레코드의 남은 첨부파일 조회
|
||||||
|
const remainingFiles = await query<any>(
|
||||||
|
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
|
||||||
|
FROM attach_file_info
|
||||||
|
WHERE target_objid = $1 AND status = 'ACTIVE'
|
||||||
|
ORDER BY regdate DESC`,
|
||||||
|
[targetObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
// attachments JSONB 형태로 변환
|
||||||
|
const attachmentsJson = remainingFiles.map((f: any) => ({
|
||||||
|
objid: f.objid.toString(),
|
||||||
|
realFileName: f.real_file_name,
|
||||||
|
fileSize: Number(f.file_size),
|
||||||
|
fileExt: f.file_ext,
|
||||||
|
filePath: f.file_path,
|
||||||
|
regdate: f.regdate?.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 테이블의 attachments 컬럼 업데이트
|
||||||
|
// 🔒 멀티테넌시: company_code 필터 추가
|
||||||
|
await query(
|
||||||
|
`UPDATE ${tableName}
|
||||||
|
SET ${columnName} = $1::jsonb, updated_date = NOW()
|
||||||
|
WHERE id = $2 AND company_code = $3`,
|
||||||
|
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
remainingFiles: attachmentsJson.length,
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
|
||||||
|
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "파일이 삭제되었습니다.",
|
message: "파일이 삭제되었습니다.",
|
||||||
|
|
|
||||||
|
|
@ -837,4 +837,53 @@ export class FlowController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
*/
|
||||||
|
updateStepData = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { flowId, stepId, recordId } = req.params;
|
||||||
|
const updateData = req.body;
|
||||||
|
const userId = (req as any).user?.userId || "system";
|
||||||
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
|
if (!flowId || !stepId || !recordId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "flowId, stepId, and recordId are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateData || Object.keys(updateData).length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "Update data is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.flowExecutionService.updateStepData(
|
||||||
|
parseInt(flowId),
|
||||||
|
parseInt(stepId),
|
||||||
|
recordId,
|
||||||
|
updateData,
|
||||||
|
userId,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Data updated successfully",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating step data:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "Failed to update step data",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 코드로 라벨 조회
|
||||||
|
*
|
||||||
|
* POST /api/table-categories/labels-by-codes
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"])
|
||||||
|
*
|
||||||
|
* Response:
|
||||||
|
* - { [code]: label } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
const { valueCodes } = req.body;
|
||||||
|
|
||||||
|
if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("카테고리 코드로 라벨 조회", {
|
||||||
|
valueCodes,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const labels = await tableCategoryValueService.getCategoryLabelsByCodes(
|
||||||
|
valueCodes,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: labels,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`카테고리 라벨 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리 라벨 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2레벨 메뉴 목록 조회
|
* 2레벨 메뉴 목록 조회
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
// 이력 조회 쿼리
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -84,7 +84,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
ORDER BY changed_at DESC
|
ORDER BY log_id DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ export class TableHistoryController {
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
// 이력 조회 쿼리
|
// 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결)
|
||||||
const historyQuery = `
|
const historyQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
log_id,
|
log_id,
|
||||||
|
|
@ -213,7 +213,7 @@ export class TableHistoryController {
|
||||||
full_row_after
|
full_row_after
|
||||||
FROM ${logTableName}
|
FROM ${logTableName}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY changed_at DESC
|
ORDER BY log_id DESC
|
||||||
LIMIT ${limitParam} OFFSET ${offsetParam}
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1811,3 +1811,334 @@ export async function getCategoryColumnsByMenu(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 범용 다중 테이블 저장 API
|
||||||
|
*
|
||||||
|
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||||
|
*
|
||||||
|
* 요청 본문:
|
||||||
|
* {
|
||||||
|
* mainTable: { tableName: string, primaryKeyColumn: string },
|
||||||
|
* mainData: Record<string, any>,
|
||||||
|
* subTables: Array<{
|
||||||
|
* tableName: string,
|
||||||
|
* linkColumn: { mainField: string, subColumn: string },
|
||||||
|
* items: Record<string, any>[],
|
||||||
|
* options?: {
|
||||||
|
* saveMainAsFirst?: boolean,
|
||||||
|
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
|
||||||
|
* mainMarkerColumn?: string,
|
||||||
|
* mainMarkerValue?: any,
|
||||||
|
* subMarkerValue?: any,
|
||||||
|
* deleteExistingBefore?: boolean,
|
||||||
|
* }
|
||||||
|
* }>,
|
||||||
|
* isUpdate?: boolean
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function multiTableSave(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const pool = require("../database/db").getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { mainTable, mainData, subTables, isUpdate } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
logger.info("=== 다중 테이블 저장 시작 ===", {
|
||||||
|
mainTable,
|
||||||
|
mainDataKeys: Object.keys(mainData || {}),
|
||||||
|
subTablesCount: subTables?.length || 0,
|
||||||
|
isUpdate,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "메인 테이블 설정이 올바르지 않습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mainData || Object.keys(mainData).length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "저장할 메인 데이터가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 메인 테이블 저장
|
||||||
|
const mainTableName = mainTable.tableName;
|
||||||
|
const pkColumn = mainTable.primaryKeyColumn;
|
||||||
|
const pkValue = mainData[pkColumn];
|
||||||
|
|
||||||
|
// company_code 자동 추가 (최고 관리자가 아닌 경우)
|
||||||
|
if (companyCode !== "*" && !mainData.company_code) {
|
||||||
|
mainData.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainResult: any;
|
||||||
|
|
||||||
|
if (isUpdate && pkValue) {
|
||||||
|
// UPDATE
|
||||||
|
const updateColumns = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
const updateValues = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map(col => mainData[col]);
|
||||||
|
|
||||||
|
// updated_at 컬럼 존재 여부 확인
|
||||||
|
const hasUpdatedAt = await client.query(`
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||||
|
`, [mainTableName]);
|
||||||
|
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE "${mainTableName}"
|
||||||
|
SET ${updateColumns}${updatedAtClause}
|
||||||
|
WHERE "${pkColumn}" = $${updateValues.length + 1}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateParams = companyCode !== "*"
|
||||||
|
? [...updateValues, pkValue, companyCode]
|
||||||
|
: [...updateValues, pkValue];
|
||||||
|
|
||||||
|
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
|
||||||
|
mainResult = await client.query(updateQuery, updateParams);
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
|
||||||
|
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const values = Object.values(mainData);
|
||||||
|
|
||||||
|
// updated_at 컬럼 존재 여부 확인
|
||||||
|
const hasUpdatedAt = await client.query(`
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'updated_at'
|
||||||
|
`, [mainTableName]);
|
||||||
|
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
|
||||||
|
|
||||||
|
const updateSetClause = Object.keys(mainData)
|
||||||
|
.filter(col => col !== pkColumn)
|
||||||
|
.map(col => `"${col}" = EXCLUDED."${col}"`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO "${mainTableName}" (${columns})
|
||||||
|
VALUES (${placeholders})
|
||||||
|
ON CONFLICT ("${pkColumn}") DO UPDATE SET
|
||||||
|
${updateSetClause}${updatedAtClause}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
|
||||||
|
mainResult = await client.query(insertQuery, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainResult.rowCount === 0) {
|
||||||
|
throw new Error("메인 테이블 저장 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedMainData = mainResult.rows[0];
|
||||||
|
const savedPkValue = savedMainData[pkColumn];
|
||||||
|
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
|
||||||
|
|
||||||
|
// 2. 서브 테이블 저장
|
||||||
|
const subTableResults: any[] = [];
|
||||||
|
|
||||||
|
for (const subTableConfig of subTables || []) {
|
||||||
|
const { tableName, linkColumn, items, options } = subTableConfig;
|
||||||
|
|
||||||
|
if (!tableName || !items || items.length === 0) {
|
||||||
|
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
|
||||||
|
itemsCount: items.length,
|
||||||
|
linkColumn,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 데이터 삭제 옵션
|
||||||
|
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
|
||||||
|
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||||
|
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
|
||||||
|
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
|
||||||
|
|
||||||
|
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
|
||||||
|
? [savedPkValue, options.subMarkerValue ?? false]
|
||||||
|
: [savedPkValue];
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
|
||||||
|
await client.query(deleteQuery, deleteParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 데이터도 서브 테이블에 저장 (옵션)
|
||||||
|
if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) {
|
||||||
|
const mainSubItem: Record<string, any> = {
|
||||||
|
[linkColumn.subColumn]: savedPkValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 메인 필드 매핑 적용
|
||||||
|
for (const mapping of options.mainFieldMappings) {
|
||||||
|
if (mapping.formField && mapping.targetColumn) {
|
||||||
|
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 마커 설정
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// company_code 추가
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
mainSubItem.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
|
||||||
|
const checkQuery = `
|
||||||
|
SELECT * FROM "${tableName}"
|
||||||
|
WHERE "${linkColumn.subColumn}" = $1
|
||||||
|
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
const checkParams: any[] = [savedPkValue];
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
checkParams.push(options.mainMarkerValue ?? true);
|
||||||
|
}
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
checkParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResult = await client.query(checkQuery, checkParams);
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
// UPDATE
|
||||||
|
const updateColumns = Object.keys(mainSubItem)
|
||||||
|
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const updateValues = Object.keys(mainSubItem)
|
||||||
|
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
|
||||||
|
.map(col => mainSubItem[col]);
|
||||||
|
|
||||||
|
if (updateColumns) {
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE "${tableName}"
|
||||||
|
SET ${updateColumns}
|
||||||
|
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
|
||||||
|
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
|
||||||
|
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const updateParams = [...updateValues, savedPkValue];
|
||||||
|
if (options.mainMarkerColumn) {
|
||||||
|
updateParams.push(options.mainMarkerValue ?? true);
|
||||||
|
}
|
||||||
|
if (companyCode !== "*") {
|
||||||
|
updateParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await client.query(updateQuery, updateParams);
|
||||||
|
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
|
||||||
|
} else {
|
||||||
|
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// INSERT
|
||||||
|
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
|
||||||
|
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const mainSubValues = Object.values(mainSubItem);
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO "${tableName}" (${mainSubColumns})
|
||||||
|
VALUES (${mainSubPlaceholders})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertResult = await client.query(insertQuery, mainSubValues);
|
||||||
|
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서브 아이템들 저장
|
||||||
|
for (const item of items) {
|
||||||
|
// 연결 컬럼 값 설정
|
||||||
|
if (linkColumn?.subColumn) {
|
||||||
|
item[linkColumn.subColumn] = savedPkValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// company_code 추가
|
||||||
|
if (companyCode !== "*" && !item.company_code) {
|
||||||
|
item.company_code = companyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
|
||||||
|
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
|
||||||
|
const subValues = Object.values(item);
|
||||||
|
|
||||||
|
const subInsertQuery = `
|
||||||
|
INSERT INTO "${tableName}" (${subColumns})
|
||||||
|
VALUES (${subPlaceholders})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
|
||||||
|
const subResult = await client.query(subInsertQuery, subValues);
|
||||||
|
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`서브 테이블 ${tableName} 저장 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("=== 다중 테이블 저장 완료 ===", {
|
||||||
|
mainTable: mainTableName,
|
||||||
|
mainPk: savedPkValue,
|
||||||
|
subTableResultsCount: subTableResults.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "다중 테이블 저장이 완료되었습니다.",
|
||||||
|
data: {
|
||||||
|
main: savedMainData,
|
||||||
|
subTables: subTableResults,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
|
||||||
|
logger.error("다중 테이블 저장 실패:", {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "다중 테이블 저장에 실패했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,365 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 컨트롤러
|
||||||
|
* 세금계산서 API 엔드포인트 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { TaxInvoiceService } from "../services/taxInvoiceService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
userId: string;
|
||||||
|
companyCode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaxInvoiceController {
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 조회
|
||||||
|
* GET /api/tax-invoice
|
||||||
|
*/
|
||||||
|
static async getList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
page = "1",
|
||||||
|
pageSize = "20",
|
||||||
|
invoice_type,
|
||||||
|
invoice_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
search,
|
||||||
|
buyer_name,
|
||||||
|
cost_type,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getList(companyCode, {
|
||||||
|
page: parseInt(page as string, 10),
|
||||||
|
pageSize: parseInt(pageSize as string, 10),
|
||||||
|
invoice_type: invoice_type as "sales" | "purchase" | undefined,
|
||||||
|
invoice_status: invoice_status as string | undefined,
|
||||||
|
start_date: start_date as string | undefined,
|
||||||
|
end_date: end_date as string | undefined,
|
||||||
|
search: search as string | undefined,
|
||||||
|
buyer_name: buyer_name as string | undefined,
|
||||||
|
cost_type: cost_type as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
pagination: {
|
||||||
|
page: result.page,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
total: result.total,
|
||||||
|
totalPages: Math.ceil(result.total / result.pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 목록 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 조회
|
||||||
|
* GET /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async getById(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.getById(id, companyCode);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 상세 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 생성
|
||||||
|
* POST /api/tax-invoice
|
||||||
|
*/
|
||||||
|
static async create(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!data.invoice_type) {
|
||||||
|
res.status(400).json({ success: false, message: "세금계산서 유형은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.invoice_date) {
|
||||||
|
res.status(400).json({ success: false, message: "작성일자는 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.supply_amount === undefined || data.supply_amount === null) {
|
||||||
|
res.status(400).json({ success: false, message: "공급가액은 필수입니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.create(data, companyCode, userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 생성되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 생성 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 생성 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수정
|
||||||
|
* PUT /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async update(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.update(id, data, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 수정 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 수정 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 삭제
|
||||||
|
* DELETE /api/tax-invoice/:id
|
||||||
|
*/
|
||||||
|
static async delete(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.delete(id, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({ success: false, message: "세금계산서를 찾을 수 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "세금계산서가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 삭제 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 삭제 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 발행
|
||||||
|
* POST /api/tax-invoice/:id/issue
|
||||||
|
*/
|
||||||
|
static async issue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await TaxInvoiceService.issue(id, companyCode, userId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "세금계산서를 찾을 수 없거나 이미 발행된 상태입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 발행되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 발행 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 발행 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 취소
|
||||||
|
* POST /api/tax-invoice/:id/cancel
|
||||||
|
*/
|
||||||
|
static async cancel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!companyCode || !userId) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.cancel(id, companyCode, userId, reason);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "세금계산서를 찾을 수 없거나 취소할 수 없는 상태입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: "세금계산서가 취소되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("세금계산서 취소 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "세금계산서 취소 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
* GET /api/tax-invoice/stats/monthly
|
||||||
|
*/
|
||||||
|
static async getMonthlyStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const now = new Date();
|
||||||
|
const targetYear = year ? parseInt(year as string, 10) : now.getFullYear();
|
||||||
|
const targetMonth = month ? parseInt(month as string, 10) : now.getMonth() + 1;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getMonthlyStats(companyCode, targetYear, targetMonth);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
period: { year: targetYear, month: targetMonth },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("월별 통계 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 조회
|
||||||
|
* GET /api/tax-invoice/stats/cost-type
|
||||||
|
*/
|
||||||
|
static async getCostTypeStats(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, month } = req.query;
|
||||||
|
const targetYear = year ? parseInt(year as string, 10) : undefined;
|
||||||
|
const targetMonth = month ? parseInt(month as string, 10) : undefined;
|
||||||
|
|
||||||
|
const result = await TaxInvoiceService.getCostTypeStats(companyCode, targetYear, targetMonth);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
period: { year: targetYear, month: targetMonth },
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("비용 유형별 통계 조회 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error.message || "통계 조회 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -54,16 +54,17 @@ export const authenticateToken = (
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||||
);
|
|
||||||
|
|
||||||
|
// 토큰 만료 에러인지 확인
|
||||||
|
const isTokenExpired = errorMessage.includes("만료");
|
||||||
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: "INVALID_TOKEN",
|
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||||
details:
|
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,16 @@ export const errorHandler = (
|
||||||
// PostgreSQL 에러 처리 (pg 라이브러리)
|
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||||
if ((err as any).code) {
|
if ((err as any).code) {
|
||||||
const pgError = err as any;
|
const pgError = err as any;
|
||||||
|
// 원본 에러 메시지 로깅 (디버깅용)
|
||||||
|
console.error("🔴 PostgreSQL Error:", {
|
||||||
|
code: pgError.code,
|
||||||
|
message: pgError.message,
|
||||||
|
detail: pgError.detail,
|
||||||
|
hint: pgError.hint,
|
||||||
|
table: pgError.table,
|
||||||
|
column: pgError.column,
|
||||||
|
constraint: pgError.constraint,
|
||||||
|
});
|
||||||
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
if (pgError.code === "23505") {
|
if (pgError.code === "23505") {
|
||||||
// unique_violation
|
// unique_violation
|
||||||
|
|
@ -42,7 +52,7 @@ export const errorHandler = (
|
||||||
// 기타 무결성 제약 조건 위반
|
// 기타 무결성 제약 조건 위반
|
||||||
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||||
} else {
|
} else {
|
||||||
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
error = new AppError(`데이터베이스 오류: ${pgError.message}`, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
getDepartmentList, // 부서 목록 조회
|
getDepartmentList, // 부서 목록 조회
|
||||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
|
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||||
|
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
getCompanyByCode, // 회사 단건 조회
|
getCompanyByCode, // 회사 단건 조회
|
||||||
|
|
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||||
router.get("/users", getUserList);
|
router.get("/users", getUserList);
|
||||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||||
|
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||||
|
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
||||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||||
router.put("/profile", updateProfile); // 프로필 수정
|
router.put("/profile", updateProfile); // 프로필 수정
|
||||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* 자동 입력 (Auto-Fill) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getAutoFillGroups,
|
||||||
|
getAutoFillGroupDetail,
|
||||||
|
createAutoFillGroup,
|
||||||
|
updateAutoFillGroup,
|
||||||
|
deleteAutoFillGroup,
|
||||||
|
getAutoFillMasterOptions,
|
||||||
|
getAutoFillData,
|
||||||
|
} from "../controllers/cascadingAutoFillController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/groups", getAutoFillGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (매핑 포함)
|
||||||
|
router.get("/groups/:groupCode", getAutoFillGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/groups", createAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/groups/:groupCode", updateAutoFillGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/groups/:groupCode", deleteAutoFillGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 자동 입력 데이터 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 마스터 옵션 목록 조회
|
||||||
|
router.get("/options/:groupCode", getAutoFillMasterOptions);
|
||||||
|
|
||||||
|
// 자동 입력 데이터 조회
|
||||||
|
router.get("/data/:groupCode", getAutoFillData);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* 조건부 연쇄 (Conditional Cascading) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getConditions,
|
||||||
|
getConditionDetail,
|
||||||
|
createCondition,
|
||||||
|
updateCondition,
|
||||||
|
deleteCondition,
|
||||||
|
getFilteredOptions,
|
||||||
|
} from "../controllers/cascadingConditionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 연쇄 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getConditions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:conditionId", getConditionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createCondition);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:conditionId", updateCondition);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:conditionId", deleteCondition);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 조건부 필터링 적용 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 조건에 따른 필터링된 옵션 조회
|
||||||
|
router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* 다단계 계층 (Hierarchy) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getHierarchyGroups,
|
||||||
|
getHierarchyGroupDetail,
|
||||||
|
createHierarchyGroup,
|
||||||
|
updateHierarchyGroup,
|
||||||
|
deleteHierarchyGroup,
|
||||||
|
addLevel,
|
||||||
|
updateLevel,
|
||||||
|
deleteLevel,
|
||||||
|
getLevelOptions,
|
||||||
|
} from "../controllers/cascadingHierarchyController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 그룹 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 그룹 목록 조회
|
||||||
|
router.get("/", getHierarchyGroups);
|
||||||
|
|
||||||
|
// 그룹 상세 조회 (레벨 포함)
|
||||||
|
router.get("/:groupCode", getHierarchyGroupDetail);
|
||||||
|
|
||||||
|
// 그룹 생성
|
||||||
|
router.post("/", createHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 수정
|
||||||
|
router.put("/:groupCode", updateHierarchyGroup);
|
||||||
|
|
||||||
|
// 그룹 삭제
|
||||||
|
router.delete("/:groupCode", deleteHierarchyGroup);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 레벨 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 레벨 추가
|
||||||
|
router.post("/:groupCode/levels", addLevel);
|
||||||
|
|
||||||
|
// 레벨 수정
|
||||||
|
router.put("/levels/:levelId", updateLevel);
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
router.delete("/levels/:levelId", deleteLevel);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 계층 옵션 조회 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 특정 레벨의 옵션 조회
|
||||||
|
router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* 상호 배제 (Mutual Exclusion) 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
getExclusions,
|
||||||
|
getExclusionDetail,
|
||||||
|
createExclusion,
|
||||||
|
updateExclusion,
|
||||||
|
deleteExclusion,
|
||||||
|
validateExclusion,
|
||||||
|
getExcludedOptions,
|
||||||
|
} from "../controllers/cascadingMutualExclusionController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 규칙 관리 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 규칙 목록 조회
|
||||||
|
router.get("/", getExclusions);
|
||||||
|
|
||||||
|
// 규칙 상세 조회
|
||||||
|
router.get("/:exclusionId", getExclusionDetail);
|
||||||
|
|
||||||
|
// 규칙 생성
|
||||||
|
router.post("/", createExclusion);
|
||||||
|
|
||||||
|
// 규칙 수정
|
||||||
|
router.put("/:exclusionId", updateExclusion);
|
||||||
|
|
||||||
|
// 규칙 삭제
|
||||||
|
router.delete("/:exclusionId", deleteExclusion);
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// 상호 배제 검증 및 옵션 API (실제 사용)
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
// 상호 배제 검증
|
||||||
|
router.post("/validate/:exclusionCode", validateExclusion);
|
||||||
|
|
||||||
|
// 배제된 옵션 조회
|
||||||
|
router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
getCascadingRelations,
|
||||||
|
getCascadingRelationById,
|
||||||
|
getCascadingRelationByCode,
|
||||||
|
createCascadingRelation,
|
||||||
|
updateCascadingRelation,
|
||||||
|
deleteCascadingRelation,
|
||||||
|
getCascadingOptions,
|
||||||
|
getParentOptions,
|
||||||
|
} from "../controllers/cascadingRelationController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 조회
|
||||||
|
router.get("/", getCascadingRelations);
|
||||||
|
|
||||||
|
// 연쇄 관계 상세 조회 (ID)
|
||||||
|
router.get("/:id", getCascadingRelationById);
|
||||||
|
|
||||||
|
// 연쇄 관계 코드로 조회
|
||||||
|
router.get("/code/:code", getCascadingRelationByCode);
|
||||||
|
|
||||||
|
// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용)
|
||||||
|
router.get("/parent-options/:code", getParentOptions);
|
||||||
|
|
||||||
|
// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용)
|
||||||
|
router.get("/options/:code", getCascadingOptions);
|
||||||
|
|
||||||
|
// 연쇄 관계 생성
|
||||||
|
router.post("/", createCascadingRelation);
|
||||||
|
|
||||||
|
// 연쇄 관계 수정
|
||||||
|
router.put("/:id", updateCascadingRelation);
|
||||||
|
|
||||||
|
// 연쇄 관계 삭제
|
||||||
|
router.delete("/:id", deleteCascadingRelation);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -218,46 +218,62 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
* 플로우 실행
|
* 플로우 실행
|
||||||
* POST /api/dataflow/node-flows/:flowId/execute
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
*/
|
*/
|
||||||
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
router.post(
|
||||||
try {
|
"/:flowId/execute",
|
||||||
const { flowId } = req.params;
|
authenticateToken,
|
||||||
const contextData = req.body;
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
const contextData = req.body;
|
||||||
|
|
||||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||||
contextDataKeys: Object.keys(contextData),
|
contextDataKeys: Object.keys(contextData),
|
||||||
userId: req.user?.userId,
|
userId: req.user?.userId,
|
||||||
companyCode: req.user?.companyCode,
|
companyCode: req.user?.companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 사용자 정보를 contextData에 추가
|
// 🔍 디버깅: req.user 전체 확인
|
||||||
const enrichedContextData = {
|
logger.info(`🔍 req.user 전체 정보:`, {
|
||||||
...contextData,
|
user: req.user,
|
||||||
userId: req.user?.userId,
|
hasUser: !!req.user,
|
||||||
userName: req.user?.userName,
|
});
|
||||||
companyCode: req.user?.companyCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 플로우 실행
|
// 사용자 정보를 contextData에 추가
|
||||||
const result = await NodeFlowExecutionService.executeFlow(
|
const enrichedContextData = {
|
||||||
parseInt(flowId, 10),
|
...contextData,
|
||||||
enrichedContextData
|
userId: req.user?.userId,
|
||||||
);
|
userName: req.user?.userName,
|
||||||
|
companyCode: req.user?.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
return res.json({
|
// 🔍 디버깅: enrichedContextData 확인
|
||||||
success: result.success,
|
logger.info(`🔍 enrichedContextData:`, {
|
||||||
message: result.message,
|
userId: enrichedContextData.userId,
|
||||||
data: result,
|
companyCode: enrichedContextData.companyCode,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
logger.error("플로우 실행 실패:", error);
|
// 플로우 실행
|
||||||
return res.status(500).json({
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
success: false,
|
parseInt(flowId, 10),
|
||||||
message:
|
enrichedContextData
|
||||||
error instanceof Error
|
);
|
||||||
? error.message
|
|
||||||
: "플로우 실행 중 오류가 발생했습니다.",
|
return res.json({
|
||||||
});
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 실행 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "플로우 실행 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
|
||||||
router.post("/move", flowController.moveData);
|
router.post("/move", flowController.moveData);
|
||||||
router.post("/move-batch", flowController.moveBatchData);
|
router.post("/move-batch", flowController.moveBatchData);
|
||||||
|
|
||||||
|
// ==================== 스텝 데이터 수정 (인라인 편집) ====================
|
||||||
|
router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData);
|
||||||
|
|
||||||
// ==================== 오딧 로그 ====================
|
// ==================== 오딧 로그 ====================
|
||||||
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
|
||||||
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
deleteColumnMapping,
|
deleteColumnMapping,
|
||||||
deleteColumnMappingsByColumn,
|
deleteColumnMappingsByColumn,
|
||||||
getSecondLevelMenus,
|
getSecondLevelMenus,
|
||||||
|
getCategoryLabelsByCodes,
|
||||||
} from "../controllers/tableCategoryValueController";
|
} from "../controllers/tableCategoryValueController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
|
||||||
// 카테고리 값 순서 변경
|
// 카테고리 값 순서 변경
|
||||||
router.post("/values/reorder", reorderCategoryValues);
|
router.post("/values/reorder", reorderCategoryValues);
|
||||||
|
|
||||||
|
// 카테고리 코드로 라벨 조회
|
||||||
|
router.post("/labels-by-codes", getCategoryLabelsByCodes);
|
||||||
|
|
||||||
// ================================================
|
// ================================================
|
||||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||||
// ================================================
|
// ================================================
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
getLogData,
|
getLogData,
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
*/
|
*/
|
||||||
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 범용 다중 테이블 저장 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다중 테이블 저장 (메인 + 서브 테이블)
|
||||||
|
* POST /api/table-management/multi-table-save
|
||||||
|
*
|
||||||
|
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
|
||||||
|
* 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다.
|
||||||
|
*/
|
||||||
|
router.post("/multi-table-save", multiTableSave);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 라우터
|
||||||
|
* /api/tax-invoice 경로 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { TaxInvoiceController } from "../controllers/taxInvoiceController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
router.get("/", TaxInvoiceController.getList);
|
||||||
|
|
||||||
|
// 월별 통계 (상세 조회보다 먼저 정의해야 함)
|
||||||
|
router.get("/stats/monthly", TaxInvoiceController.getMonthlyStats);
|
||||||
|
|
||||||
|
// 비용 유형별 통계
|
||||||
|
router.get("/stats/cost-type", TaxInvoiceController.getCostTypeStats);
|
||||||
|
|
||||||
|
// 상세 조회
|
||||||
|
router.get("/:id", TaxInvoiceController.getById);
|
||||||
|
|
||||||
|
// 생성
|
||||||
|
router.post("/", TaxInvoiceController.create);
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
router.put("/:id", TaxInvoiceController.update);
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
router.delete("/:id", TaxInvoiceController.delete);
|
||||||
|
|
||||||
|
// 발행
|
||||||
|
router.post("/:id/issue", TaxInvoiceController.issue);
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
router.post("/:id/cancel", TaxInvoiceController.cancel);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -19,15 +19,21 @@ export class AdminService {
|
||||||
|
|
||||||
// menuType에 따른 WHERE 조건 생성
|
// menuType에 따른 WHERE 조건 생성
|
||||||
const menuTypeCondition =
|
const menuTypeCondition =
|
||||||
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
|
menuType !== undefined
|
||||||
|
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
|
||||||
|
: "1 = 1";
|
||||||
|
|
||||||
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
|
||||||
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
|
||||||
const includeInactive = paramMap.includeInactive === true;
|
const includeInactive = paramMap.includeInactive === true;
|
||||||
const isManagementScreen = includeInactive || menuType === undefined;
|
const isManagementScreen = includeInactive || menuType === undefined;
|
||||||
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
|
||||||
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
|
const statusCondition = isManagementScreen
|
||||||
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
|
? "1 = 1"
|
||||||
|
: "MENU.STATUS = 'active'";
|
||||||
|
const subStatusCondition = isManagementScreen
|
||||||
|
? "1 = 1"
|
||||||
|
: "MENU_SUB.STATUS = 'active'";
|
||||||
|
|
||||||
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
|
||||||
let authFilter = "";
|
let authFilter = "";
|
||||||
|
|
@ -35,7 +41,11 @@ export class AdminService {
|
||||||
let queryParams: any[] = [userLang];
|
let queryParams: any[] = [userLang];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
|
if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType !== "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
|
||||||
const userRoleGroups = await query<any>(
|
const userRoleGroups = await query<any>(
|
||||||
`
|
`
|
||||||
|
|
@ -56,45 +66,45 @@ export class AdminService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userType === "COMPANY_ADMIN") {
|
if (userType === "COMPANY_ADMIN") {
|
||||||
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
|
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||||
if (userRoleGroups.length > 0) {
|
if (userRoleGroups.length > 0) {
|
||||||
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
|
||||||
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
|
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
|
||||||
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
|
authFilter = `
|
||||||
|
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM rel_menu_auth rma
|
||||||
|
WHERE rma.menu_objid = MENU.OBJID
|
||||||
|
AND rma.auth_objid = ANY($${paramIndex + 1})
|
||||||
|
AND rma.read_yn = 'Y'
|
||||||
|
)
|
||||||
|
`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
const companyParamIndex = paramIndex;
|
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
|
// 하위 메뉴도 권한 체크
|
||||||
unionFilter = `
|
unionFilter = `
|
||||||
AND (
|
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
|
||||||
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
|
AND EXISTS (
|
||||||
OR (
|
SELECT 1
|
||||||
MENU_SUB.COMPANY_CODE = '*'
|
FROM rel_menu_auth rma
|
||||||
AND EXISTS (
|
WHERE rma.menu_objid = MENU_SUB.OBJID
|
||||||
SELECT 1
|
AND rma.auth_objid = ANY($${paramIndex})
|
||||||
FROM rel_menu_auth rma
|
AND rma.read_yn = 'Y'
|
||||||
WHERE rma.menu_objid = MENU_SUB.OBJID
|
|
||||||
AND rma.auth_objid = ANY($${paramIndex})
|
|
||||||
AND rma.read_yn = 'Y'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
queryParams.push(roleObjids);
|
queryParams.push(roleObjids);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
|
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
|
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
|
||||||
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
logger.warn(
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
|
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
|
||||||
queryParams.push(userCompanyCode);
|
|
||||||
paramIndex++;
|
|
||||||
logger.info(
|
|
||||||
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
|
|
||||||
);
|
);
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자: 권한 그룹 필수
|
// 일반 사용자: 권한 그룹 필수
|
||||||
|
|
@ -131,7 +141,11 @@ export class AdminService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
|
} else if (
|
||||||
|
menuType !== undefined &&
|
||||||
|
userType === "SUPER_ADMIN" &&
|
||||||
|
!isManagementScreen
|
||||||
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||||
|
|
@ -167,7 +181,7 @@ export class AdminService {
|
||||||
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
|
||||||
queryParams.push(userCompanyCode);
|
queryParams.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
||||||
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
|
||||||
if (unionFilter === "") {
|
if (unionFilter === "") {
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,18 @@ export class BatchSchedulerService {
|
||||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||||
);
|
);
|
||||||
|
|
||||||
const task = cron.schedule(config.cron_schedule, async () => {
|
const task = cron.schedule(
|
||||||
logger.info(
|
config.cron_schedule,
|
||||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
async () => {
|
||||||
);
|
logger.info(
|
||||||
await this.executeBatchConfig(config);
|
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||||
});
|
);
|
||||||
|
await this.executeBatchConfig(config);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
this.scheduledTasks.set(config.id, task);
|
this.scheduledTasks.set(config.id, task);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
/**
|
/**
|
||||||
* 동적 데이터 서비스
|
* 동적 데이터 서비스
|
||||||
*
|
*
|
||||||
* 주요 특징:
|
* 주요 특징:
|
||||||
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
||||||
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
||||||
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
||||||
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
||||||
*
|
*
|
||||||
* 보안:
|
* 보안:
|
||||||
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
||||||
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
||||||
|
|
@ -70,11 +70,11 @@ class DataService {
|
||||||
|
|
||||||
// 그룹별로 데이터 분류
|
// 그룹별로 데이터 분류
|
||||||
const groups: Record<string, any[]> = {};
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const row of data) {
|
for (const row of data) {
|
||||||
const groupKey = row[config.groupByColumn];
|
const groupKey = row[config.groupByColumn];
|
||||||
if (groupKey === undefined || groupKey === null) continue;
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
if (!groups[groupKey]) {
|
if (!groups[groupKey]) {
|
||||||
groups[groupKey] = [];
|
groups[groupKey] = [];
|
||||||
}
|
}
|
||||||
|
|
@ -83,12 +83,12 @@ class DataService {
|
||||||
|
|
||||||
// 각 그룹에서 하나의 행만 선택
|
// 각 그룹에서 하나의 행만 선택
|
||||||
const result: any[] = [];
|
const result: any[] = [];
|
||||||
|
|
||||||
for (const [groupKey, rows] of Object.entries(groups)) {
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
if (rows.length === 0) continue;
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
let selectedRow: any;
|
let selectedRow: any;
|
||||||
|
|
||||||
switch (config.keepStrategy) {
|
switch (config.keepStrategy) {
|
||||||
case "latest":
|
case "latest":
|
||||||
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
|
@ -103,7 +103,7 @@ class DataService {
|
||||||
}
|
}
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "earliest":
|
case "earliest":
|
||||||
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
if (config.sortColumn) {
|
if (config.sortColumn) {
|
||||||
|
|
@ -117,38 +117,41 @@ class DataService {
|
||||||
}
|
}
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "base_price":
|
case "base_price":
|
||||||
// base_price = true인 행 찾기
|
// base_price = true인 행 찾기
|
||||||
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
selectedRow = rows.find((row) => row.base_price === true) || rows[0];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "current_date":
|
case "current_date":
|
||||||
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||||
|
|
||||||
selectedRow = rows.find(row => {
|
selectedRow =
|
||||||
const startDate = row.start_date ? new Date(row.start_date) : null;
|
rows.find((row) => {
|
||||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
const startDate = row.start_date
|
||||||
|
? new Date(row.start_date)
|
||||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
: null;
|
||||||
if (endDate) endDate.setHours(0, 0, 0, 0);
|
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||||
|
|
||||||
const afterStart = !startDate || today >= startDate;
|
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||||
const beforeEnd = !endDate || today <= endDate;
|
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
return afterStart && beforeEnd;
|
const afterStart = !startDate || today >= startDate;
|
||||||
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
const beforeEnd = !endDate || today <= endDate;
|
||||||
|
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
selectedRow = rows[0];
|
selectedRow = rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
result.push(selectedRow);
|
result.push(selectedRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,12 +233,17 @@ class DataService {
|
||||||
|
|
||||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
tableName,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`company_code = $${paramIndex}`);
|
||||||
queryParams.push(userCompany);
|
queryParams.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +516,8 @@ class DataService {
|
||||||
const entityJoinService = new EntityJoinService();
|
const entityJoinService = new EntityJoinService();
|
||||||
|
|
||||||
// Entity Join 구성 감지
|
// Entity Join 구성 감지
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(tableName);
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||||
|
|
@ -518,7 +527,7 @@ class DataService {
|
||||||
tableName,
|
tableName,
|
||||||
joinConfigs,
|
joinConfigs,
|
||||||
["*"],
|
["*"],
|
||||||
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await pool.query(joinQuery, [id]);
|
const result = await pool.query(joinQuery, [id]);
|
||||||
|
|
@ -533,14 +542,14 @@ class DataService {
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -551,17 +560,20 @@ class DataService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
console.log(
|
||||||
|
`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`,
|
||||||
|
normalizedRows[0]
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||||
if (groupByColumns.length > 0) {
|
if (groupByColumns.length > 0) {
|
||||||
const baseRecord = result.rows[0];
|
const baseRecord = result.rows[0];
|
||||||
|
|
||||||
// 그룹핑 컬럼들의 값 추출
|
// 그룹핑 컬럼들의 값 추출
|
||||||
const groupConditions: string[] = [];
|
const groupConditions: string[] = [];
|
||||||
const groupValues: any[] = [];
|
const groupValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
for (const col of groupByColumns) {
|
for (const col of groupByColumns) {
|
||||||
const value = normalizedRows[0][col];
|
const value = normalizedRows[0][col];
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
|
|
@ -570,12 +582,15 @@ class DataService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupConditions.length > 0) {
|
if (groupConditions.length > 0) {
|
||||||
const groupWhereClause = groupConditions.join(" AND ");
|
const groupWhereClause = groupConditions.join(" AND ");
|
||||||
|
|
||||||
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
console.log(
|
||||||
|
`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`,
|
||||||
|
groupValues
|
||||||
|
);
|
||||||
|
|
||||||
// 그룹핑 기준으로 모든 레코드 조회
|
// 그룹핑 기준으로 모든 레코드 조회
|
||||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -583,12 +598,14 @@ class DataService {
|
||||||
["*"],
|
["*"],
|
||||||
groupWhereClause
|
groupWhereClause
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupResult = await pool.query(groupQuery, groupValues);
|
const groupResult = await pool.query(groupQuery, groupValues);
|
||||||
|
|
||||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
console.log(
|
||||||
|
`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||||
|
|
@ -642,7 +659,8 @@ class DataService {
|
||||||
dataFilter?: any, // 🆕 데이터 필터
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||||
deduplication?: { // 🆕 중복 제거 설정
|
deduplication?: {
|
||||||
|
// 🆕 중복 제거 설정
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupByColumn: string;
|
groupByColumn: string;
|
||||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
|
@ -666,36 +684,41 @@ class DataService {
|
||||||
if (enableEntityJoin) {
|
if (enableEntityJoin) {
|
||||||
try {
|
try {
|
||||||
const { entityJoinService } = await import("./entityJoinService");
|
const { entityJoinService } = await import("./entityJoinService");
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
const joinConfigs =
|
||||||
|
await entityJoinService.detectEntityJoins(rightTable);
|
||||||
|
|
||||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||||
if (displayColumns && Array.isArray(displayColumns)) {
|
if (displayColumns && Array.isArray(displayColumns)) {
|
||||||
// 테이블별로 요청된 컬럼들을 그룹핑
|
// 테이블별로 요청된 컬럼들을 그룹핑
|
||||||
const tableColumns: Record<string, Set<string>> = {};
|
const tableColumns: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
for (const col of displayColumns) {
|
for (const col of displayColumns) {
|
||||||
if (col.name && col.name.includes('.')) {
|
if (col.name && col.name.includes(".")) {
|
||||||
const [refTable, refColumn] = col.name.split('.');
|
const [refTable, refColumn] = col.name.split(".");
|
||||||
if (!tableColumns[refTable]) {
|
if (!tableColumns[refTable]) {
|
||||||
tableColumns[refTable] = new Set();
|
tableColumns[refTable] = new Set();
|
||||||
}
|
}
|
||||||
tableColumns[refTable].add(refColumn);
|
tableColumns[refTable].add(refColumn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 각 테이블별로 처리
|
// 각 테이블별로 처리
|
||||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||||
// 이미 조인 설정에 있는지 확인
|
// 이미 조인 설정에 있는지 확인
|
||||||
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
const existingJoins = joinConfigs.filter(
|
||||||
|
(jc) => jc.referenceTable === refTable
|
||||||
|
);
|
||||||
|
|
||||||
if (existingJoins.length > 0) {
|
if (existingJoins.length > 0) {
|
||||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||||
for (const refColumn of refColumns) {
|
for (const refColumn of refColumns) {
|
||||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||||
const existingJoin = existingJoins.find(
|
const existingJoin = existingJoins.find(
|
||||||
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
(jc) =>
|
||||||
|
jc.displayColumns.length === 1 &&
|
||||||
|
jc.displayColumns[0] === refColumn
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!existingJoin) {
|
if (!existingJoin) {
|
||||||
// 없으면 새 조인 설정 복제하여 추가
|
// 없으면 새 조인 설정 복제하여 추가
|
||||||
const baseJoin = existingJoins[0];
|
const baseJoin = existingJoins[0];
|
||||||
|
|
@ -708,7 +731,9 @@ class DataService {
|
||||||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||||
};
|
};
|
||||||
joinConfigs.push(newJoin);
|
joinConfigs.push(newJoin);
|
||||||
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
console.log(
|
||||||
|
`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -718,7 +743,9 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (joinConfigs.length > 0) {
|
if (joinConfigs.length > 0) {
|
||||||
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
console.log(
|
||||||
|
`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`
|
||||||
|
);
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereConditions: string[] = [];
|
const whereConditions: string[] = [];
|
||||||
|
|
@ -735,7 +762,10 @@ class DataService {
|
||||||
|
|
||||||
// 회사별 필터링
|
// 회사별 필터링
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
|
|
@ -744,48 +774,64 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
dataFilter &&
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const { buildDataFilterWhereClause } = await import(
|
||||||
|
"../utils/dataFilterUtil"
|
||||||
|
);
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"main",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
const whereClause =
|
||||||
|
whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||||
|
|
||||||
// Entity 조인 쿼리 빌드
|
// Entity 조인 쿼리 빌드
|
||||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||||
const selectColumns = ["*"];
|
const selectColumns = ["*"];
|
||||||
|
|
||||||
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
const { query: finalQuery, aliasMap } =
|
||||||
rightTable,
|
entityJoinService.buildJoinQuery(
|
||||||
joinConfigs,
|
rightTable,
|
||||||
selectColumns,
|
joinConfigs,
|
||||||
whereClause,
|
selectColumns,
|
||||||
"",
|
whereClause,
|
||||||
undefined,
|
"",
|
||||||
undefined
|
undefined,
|
||||||
);
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
||||||
console.log(`🔍 파라미터:`, values);
|
console.log(`🔍 파라미터:`, values);
|
||||||
|
|
||||||
const result = await pool.query(finalQuery, values);
|
const result = await pool.query(finalQuery, values);
|
||||||
|
|
||||||
// 🔧 날짜 타입 타임존 문제 해결
|
// 🔧 날짜 타입 타임존 문제 해결
|
||||||
const normalizeDates = (rows: any[]) => {
|
const normalizeDates = (rows: any[]) => {
|
||||||
return rows.map(row => {
|
return rows.map((row) => {
|
||||||
const normalized: any = {};
|
const normalized: any = {};
|
||||||
for (const [key, value] of Object.entries(row)) {
|
for (const [key, value] of Object.entries(row)) {
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
const year = value.getFullYear();
|
const year = value.getFullYear();
|
||||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(value.getDate()).padStart(2, '0');
|
const day = String(value.getDate()).padStart(2, "0");
|
||||||
normalized[key] = `${year}-${month}-${day}`;
|
normalized[key] = `${year}-${month}-${day}`;
|
||||||
} else {
|
} else {
|
||||||
normalized[key] = value;
|
normalized[key] = value;
|
||||||
|
|
@ -794,18 +840,24 @@ class DataService {
|
||||||
return normalized;
|
return normalized;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedRows = normalizeDates(result.rows);
|
const normalizedRows = normalizeDates(result.rows);
|
||||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
console.log(
|
||||||
|
`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`
|
||||||
|
);
|
||||||
|
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = normalizedRows;
|
let finalData = normalizedRows;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: finalData,
|
data: finalData,
|
||||||
|
|
@ -838,23 +890,40 @@ class DataService {
|
||||||
|
|
||||||
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
// 우측 테이블 회사별 필터링 (company_code 컬럼이 있는 경우)
|
||||||
if (userCompany && userCompany !== "*") {
|
if (userCompany && userCompany !== "*") {
|
||||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
const hasCompanyCode = await this.checkColumnExists(
|
||||||
|
rightTable,
|
||||||
|
"company_code"
|
||||||
|
);
|
||||||
if (hasCompanyCode) {
|
if (hasCompanyCode) {
|
||||||
whereConditions.push(`r.company_code = $${paramIndex}`);
|
whereConditions.push(`r.company_code = $${paramIndex}`);
|
||||||
values.push(userCompany);
|
values.push(userCompany);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
console.log(`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`);
|
console.log(
|
||||||
|
`🏢 우측 패널 회사별 필터링 적용: ${rightTable}.company_code = ${userCompany}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
|
||||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
if (
|
||||||
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
|
dataFilter &&
|
||||||
|
dataFilter.enabled &&
|
||||||
|
dataFilter.filters &&
|
||||||
|
dataFilter.filters.length > 0
|
||||||
|
) {
|
||||||
|
const filterResult = buildDataFilterWhereClause(
|
||||||
|
dataFilter,
|
||||||
|
"r",
|
||||||
|
paramIndex
|
||||||
|
);
|
||||||
if (filterResult.whereClause) {
|
if (filterResult.whereClause) {
|
||||||
whereConditions.push(filterResult.whereClause);
|
whereConditions.push(filterResult.whereClause);
|
||||||
values.push(...filterResult.params);
|
values.push(...filterResult.params);
|
||||||
paramIndex += filterResult.params.length;
|
paramIndex += filterResult.params.length;
|
||||||
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
console.log(
|
||||||
|
`🔍 데이터 필터 적용 (${rightTable}):`,
|
||||||
|
filterResult.whereClause
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -871,9 +940,13 @@ class DataService {
|
||||||
// 🆕 중복 제거 처리
|
// 🆕 중복 제거 처리
|
||||||
let finalData = result;
|
let finalData = result;
|
||||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
console.log(
|
||||||
|
`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`
|
||||||
|
);
|
||||||
finalData = this.deduplicateData(result, deduplication);
|
finalData = this.deduplicateData(result, deduplication);
|
||||||
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
console.log(
|
||||||
|
`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -907,8 +980,31 @@ class DataService {
|
||||||
return validation.error!;
|
return validation.error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = Object.keys(data);
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
const values = Object.values(data);
|
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||||
|
const validColumnNames = new Set(
|
||||||
|
tableColumns.map((col: any) => col.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidColumns: string[] = [];
|
||||||
|
const filteredData = Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([key]) => {
|
||||||
|
if (validColumnNames.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
invalidColumns.push(key);
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidColumns.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(filteredData);
|
||||||
|
const values = Object.values(filteredData);
|
||||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||||
|
|
||||||
|
|
@ -951,9 +1047,32 @@ class DataService {
|
||||||
|
|
||||||
// _relationInfo 추출 (조인 관계 업데이트용)
|
// _relationInfo 추출 (조인 관계 업데이트용)
|
||||||
const relationInfo = data._relationInfo;
|
const relationInfo = data._relationInfo;
|
||||||
const cleanData = { ...data };
|
let cleanData = { ...data };
|
||||||
delete cleanData._relationInfo;
|
delete cleanData._relationInfo;
|
||||||
|
|
||||||
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외)
|
||||||
|
const tableColumns = await this.getTableColumnsSimple(tableName);
|
||||||
|
const validColumnNames = new Set(
|
||||||
|
tableColumns.map((col: any) => col.column_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidColumns: string[] = [];
|
||||||
|
cleanData = Object.fromEntries(
|
||||||
|
Object.entries(cleanData).filter(([key]) => {
|
||||||
|
if (validColumnNames.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
invalidColumns.push(key);
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidColumns.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Primary Key 컬럼 찾기
|
// Primary Key 컬럼 찾기
|
||||||
const pkResult = await query<{ attname: string }>(
|
const pkResult = await query<{ attname: string }>(
|
||||||
`SELECT a.attname
|
`SELECT a.attname
|
||||||
|
|
@ -993,8 +1112,14 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
|
||||||
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
|
if (
|
||||||
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
|
relationInfo &&
|
||||||
|
relationInfo.rightTable &&
|
||||||
|
relationInfo.leftColumn &&
|
||||||
|
relationInfo.rightColumn
|
||||||
|
) {
|
||||||
|
const { rightTable, leftColumn, rightColumn, oldLeftValue } =
|
||||||
|
relationInfo;
|
||||||
const newLeftValue = cleanData[leftColumn];
|
const newLeftValue = cleanData[leftColumn];
|
||||||
|
|
||||||
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
|
||||||
|
|
@ -1012,8 +1137,13 @@ class DataService {
|
||||||
SET "${rightColumn}" = $1
|
SET "${rightColumn}" = $1
|
||||||
WHERE "${rightColumn}" = $2
|
WHERE "${rightColumn}" = $2
|
||||||
`;
|
`;
|
||||||
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
|
const updateResult = await query(updateRelatedQuery, [
|
||||||
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
|
newLeftValue,
|
||||||
|
oldLeftValue,
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`
|
||||||
|
);
|
||||||
} catch (relError) {
|
} catch (relError) {
|
||||||
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
|
||||||
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
|
||||||
|
|
@ -1064,9 +1194,11 @@ class DataService {
|
||||||
|
|
||||||
if (pkResult.length > 1) {
|
if (pkResult.length > 1) {
|
||||||
// 복합키인 경우: id가 객체여야 함
|
// 복합키인 경우: id가 객체여야 함
|
||||||
console.log(`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map(r => r.attname).join(', ')}]`);
|
console.log(
|
||||||
|
`🔑 복합키 테이블: ${tableName}, PK: [${pkResult.map((r) => r.attname).join(", ")}]`
|
||||||
if (typeof id === 'object' && !Array.isArray(id)) {
|
);
|
||||||
|
|
||||||
|
if (typeof id === "object" && !Array.isArray(id)) {
|
||||||
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
// id가 객체인 경우: { user_id: 'xxx', dept_code: 'yyy' }
|
||||||
pkResult.forEach((pk, index) => {
|
pkResult.forEach((pk, index) => {
|
||||||
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
whereClauses.push(`"${pk.attname}" = $${index + 1}`);
|
||||||
|
|
@ -1081,15 +1213,17 @@ class DataService {
|
||||||
// 단일키인 경우
|
// 단일키인 경우
|
||||||
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
const pkColumn = pkResult.length > 0 ? pkResult[0].attname : "id";
|
||||||
whereClauses.push(`"${pkColumn}" = $1`);
|
whereClauses.push(`"${pkColumn}" = $1`);
|
||||||
params.push(typeof id === 'object' ? id[pkColumn] : id);
|
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
console.log(`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`);
|
console.log(
|
||||||
|
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -1128,7 +1262,11 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (whereConditions.length === 0) {
|
if (whereConditions.length === 0) {
|
||||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "삭제 조건이 없습니다.",
|
||||||
|
error: "NO_CONDITIONS",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
@ -1163,7 +1301,9 @@ class DataService {
|
||||||
records: Array<Record<string, any>>,
|
records: Array<Record<string, any>>,
|
||||||
userCompany?: string,
|
userCompany?: string,
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
): Promise<
|
||||||
|
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 권한 검증
|
// 테이블 접근 권한 검증
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1201,11 +1341,14 @@ class DataService {
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||||
|
|
||||||
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
console.log(`📋 기존 레코드 조회:`, {
|
||||||
|
query: selectQuery,
|
||||||
|
values: whereValues,
|
||||||
|
});
|
||||||
|
|
||||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||||
|
|
||||||
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||||
|
|
||||||
// 2. 새 레코드와 기존 레코드 비교
|
// 2. 새 레코드와 기존 레코드 비교
|
||||||
|
|
@ -1216,50 +1359,53 @@ class DataService {
|
||||||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||||
const normalizeDateValue = (value: any): any => {
|
const normalizeDateValue = (value: any): any => {
|
||||||
if (value == null) return value;
|
if (value == null) return value;
|
||||||
|
|
||||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새 레코드 처리 (INSERT or UPDATE)
|
// 새 레코드 처리 (INSERT or UPDATE)
|
||||||
for (const newRecord of records) {
|
for (const newRecord of records) {
|
||||||
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
||||||
|
|
||||||
// 날짜 필드 정규화
|
// 날짜 필드 정규화
|
||||||
const normalizedRecord: Record<string, any> = {};
|
const normalizedRecord: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(newRecord)) {
|
for (const [key, value] of Object.entries(newRecord)) {
|
||||||
normalizedRecord[key] = normalizeDateValue(value);
|
normalizedRecord[key] = normalizeDateValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
||||||
|
|
||||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||||
|
|
||||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||||
const uniqueFields = Object.keys(normalizedRecord);
|
const uniqueFields = Object.keys(normalizedRecord);
|
||||||
|
|
||||||
console.log(`🔑 고유 필드들:`, uniqueFields);
|
console.log(`🔑 고유 필드들:`, uniqueFields);
|
||||||
|
|
||||||
// 기존 레코드에서 일치하는 것 찾기
|
// 기존 레코드에서 일치하는 것 찾기
|
||||||
const existingRecord = existingRecords.rows.find((existing) => {
|
const existingRecord = existingRecords.rows.find((existing) => {
|
||||||
return uniqueFields.every((field) => {
|
return uniqueFields.every((field) => {
|
||||||
const existingValue = existing[field];
|
const existingValue = existing[field];
|
||||||
const newValue = normalizedRecord[field];
|
const newValue = normalizedRecord[field];
|
||||||
|
|
||||||
// null/undefined 처리
|
// null/undefined 처리
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
// Date 타입 처리
|
// Date 타입 처리
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문자열 비교
|
// 문자열 비교
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
});
|
});
|
||||||
|
|
@ -1272,7 +1418,8 @@ class DataService {
|
||||||
let updateParamIndex = 1;
|
let updateParamIndex = 1;
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(fullRecord)) {
|
for (const [key, value] of Object.entries(fullRecord)) {
|
||||||
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
if (key !== pkColumn) {
|
||||||
|
// Primary Key는 업데이트하지 않음
|
||||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||||
updateValues.push(value);
|
updateValues.push(value);
|
||||||
updateParamIndex++;
|
updateParamIndex++;
|
||||||
|
|
@ -1288,36 +1435,42 @@ class DataService {
|
||||||
|
|
||||||
await pool.query(updateQuery, updateValues);
|
await pool.query(updateQuery, updateValues);
|
||||||
updated++;
|
updated++;
|
||||||
|
|
||||||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
} else {
|
} else {
|
||||||
// INSERT: 기존 레코드가 없으면 삽입
|
// INSERT: 기존 레코드가 없으면 삽입
|
||||||
|
|
||||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||||
|
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||||
|
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||||
const recordWithMeta: Record<string, any> = {
|
const recordWithMeta: Record<string, any> = {
|
||||||
...fullRecord,
|
...recordWithoutCreatedDate,
|
||||||
id: uuidv4(), // 새 ID 생성
|
id: uuidv4(), // 새 ID 생성
|
||||||
created_date: "NOW()",
|
created_date: "NOW()",
|
||||||
updated_date: "NOW()",
|
updated_date: "NOW()",
|
||||||
};
|
};
|
||||||
|
|
||||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
if (
|
||||||
|
!recordWithMeta.company_code &&
|
||||||
|
userCompany &&
|
||||||
|
userCompany !== "*"
|
||||||
|
) {
|
||||||
recordWithMeta.company_code = userCompany;
|
recordWithMeta.company_code = userCompany;
|
||||||
}
|
}
|
||||||
|
|
||||||
// writer가 없으면 userId 사용
|
// writer가 없으면 userId 사용
|
||||||
if (!recordWithMeta.writer && userId) {
|
if (!recordWithMeta.writer && userId) {
|
||||||
recordWithMeta.writer = userId;
|
recordWithMeta.writer = userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
const insertFields = Object.keys(recordWithMeta).filter(
|
||||||
recordWithMeta[key] !== "NOW()"
|
(key) => recordWithMeta[key] !== "NOW()"
|
||||||
);
|
);
|
||||||
const insertPlaceholders: string[] = [];
|
const insertPlaceholders: string[] = [];
|
||||||
const insertValues: any[] = [];
|
const insertValues: any[] = [];
|
||||||
let insertParamIndex = 1;
|
let insertParamIndex = 1;
|
||||||
|
|
||||||
for (const field of Object.keys(recordWithMeta)) {
|
for (const field of Object.keys(recordWithMeta)) {
|
||||||
if (recordWithMeta[field] === "NOW()") {
|
if (recordWithMeta[field] === "NOW()") {
|
||||||
insertPlaceholders.push("NOW()");
|
insertPlaceholders.push("NOW()");
|
||||||
|
|
@ -1329,15 +1482,20 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta)
|
||||||
|
.map((f) => `"${f}"`)
|
||||||
|
.join(", ")})
|
||||||
VALUES (${insertPlaceholders.join(", ")})
|
VALUES (${insertPlaceholders.join(", ")})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
console.log(`➕ INSERT 쿼리:`, {
|
||||||
|
query: insertQuery,
|
||||||
|
values: insertValues,
|
||||||
|
});
|
||||||
|
|
||||||
await pool.query(insertQuery, insertValues);
|
await pool.query(insertQuery, insertValues);
|
||||||
inserted++;
|
inserted++;
|
||||||
|
|
||||||
console.log(`➕ INSERT: 새 레코드`);
|
console.log(`➕ INSERT: 새 레코드`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1345,19 +1503,22 @@ class DataService {
|
||||||
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||||
for (const existingRecord of existingRecords.rows) {
|
for (const existingRecord of existingRecords.rows) {
|
||||||
const uniqueFields = Object.keys(records[0] || {});
|
const uniqueFields = Object.keys(records[0] || {});
|
||||||
|
|
||||||
const stillExists = records.some((newRecord) => {
|
const stillExists = records.some((newRecord) => {
|
||||||
return uniqueFields.every((field) => {
|
return uniqueFields.every((field) => {
|
||||||
const existingValue = existingRecord[field];
|
const existingValue = existingRecord[field];
|
||||||
const newValue = newRecord[field];
|
const newValue = newRecord[field];
|
||||||
|
|
||||||
if (existingValue == null && newValue == null) return true;
|
if (existingValue == null && newValue == null) return true;
|
||||||
if (existingValue == null || newValue == null) return false;
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
return (
|
||||||
|
existingValue.toISOString().split("T")[0] ===
|
||||||
|
newValue.split("T")[0]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(existingValue) === String(newValue);
|
return String(existingValue) === String(newValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1367,7 +1528,7 @@ class DataService {
|
||||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||||
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||||
deleted++;
|
deleted++;
|
||||||
|
|
||||||
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,12 +103,16 @@ export class DynamicFormService {
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
// DATE 타입이면 문자열 그대로 유지
|
// DATE 타입이면 문자열 그대로 유지
|
||||||
if (lowerDataType === "date") {
|
if (lowerDataType === "date") {
|
||||||
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
|
console.log(
|
||||||
|
`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`
|
||||||
|
);
|
||||||
return value; // 문자열 그대로 반환
|
return value; // 문자열 그대로 반환
|
||||||
}
|
}
|
||||||
// TIMESTAMP 타입이면 Date 객체로 변환
|
// TIMESTAMP 타입이면 Date 객체로 변환
|
||||||
else {
|
else {
|
||||||
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
|
console.log(
|
||||||
|
`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`
|
||||||
|
);
|
||||||
return new Date(value + "T00:00:00");
|
return new Date(value + "T00:00:00");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +254,8 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
||||||
dataToInsert.regdate = new Date();
|
dataToInsert.regdate = new Date();
|
||||||
}
|
}
|
||||||
if (tableColumns.includes("created_date") && !dataToInsert.created_date) {
|
// created_date는 항상 현재 시간으로 설정 (기존 값 무시)
|
||||||
|
if (tableColumns.includes("created_date")) {
|
||||||
dataToInsert.created_date = new Date();
|
dataToInsert.created_date = new Date();
|
||||||
}
|
}
|
||||||
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
||||||
|
|
@ -313,7 +318,9 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
||||||
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
|
console.log(
|
||||||
|
`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`
|
||||||
|
);
|
||||||
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,35 +353,37 @@ export class DynamicFormService {
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
parsedArray = JSON.parse(value);
|
parsedArray = JSON.parse(value);
|
||||||
console.log(
|
console.log(
|
||||||
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
||||||
);
|
);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파싱된 배열이 있으면 처리
|
// 파싱된 배열이 있으면 처리
|
||||||
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
if (
|
||||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
parsedArray &&
|
||||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
Array.isArray(parsedArray) &&
|
||||||
let targetTable: string | undefined;
|
parsedArray.length > 0
|
||||||
let actualData = parsedArray;
|
) {
|
||||||
|
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||||
|
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||||
|
let targetTable: string | undefined;
|
||||||
|
let actualData = parsedArray;
|
||||||
|
|
||||||
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
|
||||||
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
if (parsedArray[0] && parsedArray[0]._targetTable) {
|
||||||
targetTable = parsedArray[0]._targetTable;
|
targetTable = parsedArray[0]._targetTable;
|
||||||
actualData = parsedArray.map(
|
actualData = parsedArray.map(({ _targetTable, ...item }) => item);
|
||||||
({ _targetTable, ...item }) => item
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
repeaterData.push({
|
repeaterData.push({
|
||||||
data: actualData,
|
data: actualData,
|
||||||
targetTable,
|
targetTable,
|
||||||
componentId: key,
|
componentId: key,
|
||||||
});
|
});
|
||||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||||
|
|
||||||
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||||||
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||||||
|
|
@ -387,8 +396,8 @@ export class DynamicFormService {
|
||||||
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
||||||
const separateRepeaterData: typeof repeaterData = [];
|
const separateRepeaterData: typeof repeaterData = [];
|
||||||
const mergedRepeaterData: typeof repeaterData = [];
|
const mergedRepeaterData: typeof repeaterData = [];
|
||||||
|
|
||||||
repeaterData.forEach(repeater => {
|
repeaterData.forEach((repeater) => {
|
||||||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||||
// 다른 테이블: 나중에 별도 저장
|
// 다른 테이블: 나중에 별도 저장
|
||||||
separateRepeaterData.push(repeater);
|
separateRepeaterData.push(repeater);
|
||||||
|
|
@ -397,10 +406,10 @@ export class DynamicFormService {
|
||||||
mergedRepeaterData.push(repeater);
|
mergedRepeaterData.push(repeater);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🔄 Repeater 데이터 분류:`, {
|
console.log(`🔄 Repeater 데이터 분류:`, {
|
||||||
separate: separateRepeaterData.length, // 별도 테이블
|
separate: separateRepeaterData.length, // 별도 테이블
|
||||||
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
||||||
});
|
});
|
||||||
|
|
||||||
// 존재하지 않는 컬럼 제거
|
// 존재하지 않는 컬럼 제거
|
||||||
|
|
@ -494,44 +503,75 @@ export class DynamicFormService {
|
||||||
const clientIp = ipAddress || "unknown";
|
const clientIp = ipAddress || "unknown";
|
||||||
|
|
||||||
let result: any[];
|
let result: any[];
|
||||||
|
|
||||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||||
if (mergedRepeaterData.length > 0) {
|
if (mergedRepeaterData.length > 0) {
|
||||||
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
console.log(
|
||||||
|
`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`
|
||||||
|
);
|
||||||
|
|
||||||
result = [];
|
result = [];
|
||||||
|
|
||||||
for (const repeater of mergedRepeaterData) {
|
for (const repeater of mergedRepeaterData) {
|
||||||
for (const item of repeater.data) {
|
for (const item of repeater.data) {
|
||||||
// 헤더 + 품목을 병합
|
// 헤더 + 품목을 병합
|
||||||
const rawMergedData = { ...dataToInsert, ...item };
|
// item에서 created_date 제거 (dataToInsert의 현재 시간 유지)
|
||||||
|
const { created_date: _, ...itemWithoutCreatedDate } = item;
|
||||||
|
const rawMergedData = {
|
||||||
|
...dataToInsert,
|
||||||
|
...itemWithoutCreatedDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 새 레코드 저장 시 id 제거하여 새 UUID 생성되도록 함
|
||||||
|
// _existingRecord가 명시적으로 true인 경우에만 기존 레코드로 처리 (UPDATE)
|
||||||
|
// 그 외의 경우는 모두 새 레코드로 처리 (INSERT)
|
||||||
|
const isExistingRecord = rawMergedData._existingRecord === true;
|
||||||
|
|
||||||
|
if (!isExistingRecord) {
|
||||||
|
// 새 레코드: id 제거하여 새 UUID 자동 생성
|
||||||
|
const oldId = rawMergedData.id;
|
||||||
|
delete rawMergedData.id;
|
||||||
|
console.log(`🆕 새 레코드로 처리 (id 제거됨: ${oldId})`);
|
||||||
|
} else {
|
||||||
|
console.log(`📝 기존 레코드 수정 (id 유지: ${rawMergedData.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메타 플래그 제거
|
||||||
|
delete rawMergedData._isNewItem;
|
||||||
|
delete rawMergedData._existingRecord;
|
||||||
|
|
||||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||||
const mergedData: Record<string, any> = {};
|
const mergedData: Record<string, any> = {};
|
||||||
|
|
||||||
Object.keys(rawMergedData).forEach((columnName) => {
|
Object.keys(rawMergedData).forEach((columnName) => {
|
||||||
// 실제 테이블 컬럼인지 확인
|
// 실제 테이블 컬럼인지 확인
|
||||||
if (validColumnNames.includes(columnName)) {
|
if (validColumnNames.includes(columnName)) {
|
||||||
const column = columnInfo.find((col) => col.column_name === columnName);
|
const column = columnInfo.find(
|
||||||
if (column) {
|
(col) => col.column_name === columnName
|
||||||
// 타입 변환
|
|
||||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
|
||||||
rawMergedData[columnName],
|
|
||||||
column.data_type
|
|
||||||
);
|
);
|
||||||
|
if (column) {
|
||||||
|
// 타입 변환
|
||||||
|
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||||
|
rawMergedData[columnName],
|
||||||
|
column.data_type
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
mergedData[columnName] = rawMergedData[columnName];
|
mergedData[columnName] = rawMergedData[columnName];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
|
console.log(
|
||||||
|
`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mergedColumns = Object.keys(mergedData);
|
const mergedColumns = Object.keys(mergedData);
|
||||||
const mergedValues: any[] = Object.values(mergedData);
|
const mergedValues: any[] = Object.values(mergedData);
|
||||||
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
const mergedPlaceholders = mergedValues
|
||||||
|
.map((_, index) => `$${index + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
let mergedUpsertQuery: string;
|
let mergedUpsertQuery: string;
|
||||||
if (primaryKeys.length > 0) {
|
if (primaryKeys.length > 0) {
|
||||||
const conflictColumns = primaryKeys.join(", ");
|
const conflictColumns = primaryKeys.join(", ");
|
||||||
|
|
@ -539,7 +579,7 @@ export class DynamicFormService {
|
||||||
.filter((col) => !primaryKeys.includes(col))
|
.filter((col) => !primaryKeys.includes(col))
|
||||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
mergedUpsertQuery = updateSet
|
mergedUpsertQuery = updateSet
|
||||||
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||||
VALUES (${mergedPlaceholders})
|
VALUES (${mergedPlaceholders})
|
||||||
|
|
@ -556,20 +596,20 @@ export class DynamicFormService {
|
||||||
VALUES (${mergedPlaceholders})
|
VALUES (${mergedPlaceholders})
|
||||||
RETURNING *`;
|
RETURNING *`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📝 병합 INSERT:`, { mergedData });
|
console.log(`📝 병합 INSERT:`, { mergedData });
|
||||||
|
|
||||||
const itemResult = await transaction(async (client) => {
|
const itemResult = await transaction(async (client) => {
|
||||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||||
const res = await client.query(mergedUpsertQuery, mergedValues);
|
const res = await client.query(mergedUpsertQuery, mergedValues);
|
||||||
return res.rows[0];
|
return res.rows[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
result.push(itemResult);
|
result.push(itemResult);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
||||||
} else {
|
} else {
|
||||||
// 일반 모드: 헤더만 저장
|
// 일반 모드: 헤더만 저장
|
||||||
|
|
@ -579,7 +619,7 @@ export class DynamicFormService {
|
||||||
const res = await client.query(upsertQuery, values);
|
const res = await client.query(upsertQuery, values);
|
||||||
return res.rows;
|
return res.rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,12 +754,19 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (새로 추가)
|
// 🎯 제어관리 실행 (새로 추가)
|
||||||
try {
|
try {
|
||||||
|
// savedData 또는 insertedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
(insertedRecord as Record<string, any>)?.company_code ||
|
||||||
|
dataToInsert.company_code ||
|
||||||
|
"*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
insertedRecord as Record<string, any>,
|
insertedRecord as Record<string, any>,
|
||||||
"insert",
|
"insert",
|
||||||
created_by || "system"
|
created_by || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -825,10 +872,10 @@ export class DynamicFormService {
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1 AND table_schema = 'public'
|
WHERE table_name = $1 AND table_schema = 'public'
|
||||||
`;
|
`;
|
||||||
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
|
const columnTypesResult = await query<{
|
||||||
columnTypesQuery,
|
column_name: string;
|
||||||
[tableName]
|
data_type: string;
|
||||||
);
|
}>(columnTypesQuery, [tableName]);
|
||||||
const columnTypes: Record<string, string> = {};
|
const columnTypes: Record<string, string> = {};
|
||||||
columnTypesResult.forEach((row) => {
|
columnTypesResult.forEach((row) => {
|
||||||
columnTypes[row.column_name] = row.data_type;
|
columnTypes[row.column_name] = row.data_type;
|
||||||
|
|
@ -841,12 +888,24 @@ export class DynamicFormService {
|
||||||
.map((key, index) => {
|
.map((key, index) => {
|
||||||
const dataType = columnTypes[key];
|
const dataType = columnTypes[key];
|
||||||
// 숫자 타입인 경우 명시적 캐스팅
|
// 숫자 타입인 경우 명시적 캐스팅
|
||||||
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
|
if (
|
||||||
|
dataType === "integer" ||
|
||||||
|
dataType === "bigint" ||
|
||||||
|
dataType === "smallint"
|
||||||
|
) {
|
||||||
return `${key} = $${index + 1}::integer`;
|
return `${key} = $${index + 1}::integer`;
|
||||||
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
|
} else if (
|
||||||
|
dataType === "numeric" ||
|
||||||
|
dataType === "decimal" ||
|
||||||
|
dataType === "real" ||
|
||||||
|
dataType === "double precision"
|
||||||
|
) {
|
||||||
return `${key} = $${index + 1}::numeric`;
|
return `${key} = $${index + 1}::numeric`;
|
||||||
} else if (dataType === 'boolean') {
|
} else if (dataType === "boolean") {
|
||||||
return `${key} = $${index + 1}::boolean`;
|
return `${key} = $${index + 1}::boolean`;
|
||||||
|
} else if (dataType === 'jsonb' || dataType === 'json') {
|
||||||
|
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
|
||||||
|
return `${key} = $${index + 1}::jsonb`;
|
||||||
} else {
|
} else {
|
||||||
// 문자열 타입은 캐스팅 불필요
|
// 문자열 타입은 캐스팅 불필요
|
||||||
return `${key} = $${index + 1}`;
|
return `${key} = $${index + 1}`;
|
||||||
|
|
@ -854,18 +913,32 @@ export class DynamicFormService {
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const values: any[] = Object.values(changedFields);
|
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
|
||||||
|
const values: any[] = Object.keys(changedFields).map((key) => {
|
||||||
|
const value = changedFields[key];
|
||||||
|
const dataType = columnTypes[key];
|
||||||
|
|
||||||
|
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
|
||||||
|
if ((dataType === 'jsonb' || dataType === 'json') && (Array.isArray(value) || (typeof value === 'object' && value !== null))) {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
values.push(id); // WHERE 조건용 ID 추가
|
values.push(id); // WHERE 조건용 ID 추가
|
||||||
|
|
||||||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||||||
const pkDataType = columnTypes[primaryKeyColumn];
|
const pkDataType = columnTypes[primaryKeyColumn];
|
||||||
let pkCast = '';
|
let pkCast = "";
|
||||||
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
|
if (
|
||||||
pkCast = '::integer';
|
pkDataType === "integer" ||
|
||||||
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
|
pkDataType === "bigint" ||
|
||||||
pkCast = '::numeric';
|
pkDataType === "smallint"
|
||||||
} else if (pkDataType === 'uuid') {
|
) {
|
||||||
pkCast = '::uuid';
|
pkCast = "::integer";
|
||||||
|
} else if (pkDataType === "numeric" || pkDataType === "decimal") {
|
||||||
|
pkCast = "::numeric";
|
||||||
|
} else if (pkDataType === "uuid") {
|
||||||
|
pkCast = "::uuid";
|
||||||
}
|
}
|
||||||
// text, varchar 등은 캐스팅 불필요
|
// text, varchar 등은 캐스팅 불필요
|
||||||
|
|
||||||
|
|
@ -1054,12 +1127,19 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 🎯 제어관리 실행 (UPDATE 트리거)
|
// 🎯 제어관리 실행 (UPDATE 트리거)
|
||||||
try {
|
try {
|
||||||
|
// updatedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
(updatedRecord as Record<string, any>)?.company_code ||
|
||||||
|
company_code ||
|
||||||
|
"*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
updatedRecord as Record<string, any>,
|
updatedRecord as Record<string, any>,
|
||||||
"update",
|
"update",
|
||||||
updated_by || "system"
|
updated_by || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||||
|
|
@ -1160,7 +1240,15 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||||||
console.log("📊 SQL 파라미터:", [id]);
|
console.log("📊 SQL 파라미터:", [id]);
|
||||||
|
|
||||||
const result = await query<any>(deleteQuery, [id]);
|
// 🔥 트랜잭션 내에서 app.user_id 설정 후 DELETE 실행 (이력 트리거용)
|
||||||
|
const result = await transaction(async (client) => {
|
||||||
|
// 이력 트리거에서 사용할 사용자 정보 설정
|
||||||
|
if (userId) {
|
||||||
|
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||||
|
}
|
||||||
|
const res = await client.query(deleteQuery, [id]);
|
||||||
|
return res.rows;
|
||||||
|
});
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||||
|
|
||||||
|
|
@ -1190,12 +1278,17 @@ export class DynamicFormService {
|
||||||
try {
|
try {
|
||||||
if (result && Array.isArray(result) && result.length > 0) {
|
if (result && Array.isArray(result) && result.length > 0) {
|
||||||
const deletedRecord = result[0] as Record<string, any>;
|
const deletedRecord = result[0] as Record<string, any>;
|
||||||
|
// deletedRecord에서 company_code 추출
|
||||||
|
const recordCompanyCode =
|
||||||
|
deletedRecord?.company_code || companyCode || "*";
|
||||||
|
|
||||||
await this.executeDataflowControlIfConfigured(
|
await this.executeDataflowControlIfConfigured(
|
||||||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||||
tableName,
|
tableName,
|
||||||
deletedRecord,
|
deletedRecord,
|
||||||
"delete",
|
"delete",
|
||||||
userId || "system"
|
userId || "system",
|
||||||
|
recordCompanyCode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (controlError) {
|
} catch (controlError) {
|
||||||
|
|
@ -1501,7 +1594,8 @@ export class DynamicFormService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
savedData: Record<string, any>,
|
savedData: Record<string, any>,
|
||||||
triggerType: "insert" | "update" | "delete",
|
triggerType: "insert" | "update" | "delete",
|
||||||
userId: string = "system"
|
userId: string = "system",
|
||||||
|
companyCode: string = "*"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||||
|
|
@ -1530,9 +1624,11 @@ export class DynamicFormService {
|
||||||
componentId: layout.component_id,
|
componentId: layout.component_id,
|
||||||
componentType: properties?.componentType,
|
componentType: properties?.componentType,
|
||||||
actionType: properties?.componentConfig?.action?.type,
|
actionType: properties?.componentConfig?.action?.type,
|
||||||
enableDataflowControl: properties?.webTypeConfig?.enableDataflowControl,
|
enableDataflowControl:
|
||||||
|
properties?.webTypeConfig?.enableDataflowControl,
|
||||||
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
|
||||||
hasDiagramId: !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
hasDiagramId:
|
||||||
|
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
|
||||||
|
|
@ -1557,21 +1653,27 @@ export class DynamicFormService {
|
||||||
|
|
||||||
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
|
||||||
let controlResult: any;
|
let controlResult: any;
|
||||||
|
|
||||||
if (!relationshipId) {
|
if (!relationshipId) {
|
||||||
// 노드 플로우 실행
|
// 노드 플로우 실행
|
||||||
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
|
||||||
const { NodeFlowExecutionService } = await import("./nodeFlowExecutionService");
|
const { NodeFlowExecutionService } = await import(
|
||||||
|
"./nodeFlowExecutionService"
|
||||||
const executionResult = await NodeFlowExecutionService.executeFlow(diagramId, {
|
);
|
||||||
sourceData: [savedData],
|
|
||||||
dataSourceType: "formData",
|
const executionResult = await NodeFlowExecutionService.executeFlow(
|
||||||
buttonId: "save-button",
|
diagramId,
|
||||||
screenId: screenId,
|
{
|
||||||
userId: userId,
|
sourceData: [savedData],
|
||||||
formData: savedData,
|
dataSourceType: "formData",
|
||||||
});
|
buttonId: "save-button",
|
||||||
|
screenId: screenId,
|
||||||
|
userId: userId,
|
||||||
|
companyCode: companyCode,
|
||||||
|
formData: savedData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
controlResult = {
|
controlResult = {
|
||||||
success: executionResult.success,
|
success: executionResult.success,
|
||||||
message: executionResult.message,
|
message: executionResult.message,
|
||||||
|
|
@ -1586,15 +1688,18 @@ export class DynamicFormService {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 관계 기반 제어관리 실행
|
// 관계 기반 제어관리 실행
|
||||||
console.log(`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`);
|
console.log(
|
||||||
controlResult = await this.dataflowControlService.executeDataflowControl(
|
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
|
||||||
diagramId,
|
|
||||||
relationshipId,
|
|
||||||
triggerType,
|
|
||||||
savedData,
|
|
||||||
tableName,
|
|
||||||
userId
|
|
||||||
);
|
);
|
||||||
|
controlResult =
|
||||||
|
await this.dataflowControlService.executeDataflowControl(
|
||||||
|
diagramId,
|
||||||
|
relationshipId,
|
||||||
|
triggerType,
|
||||||
|
savedData,
|
||||||
|
tableName,
|
||||||
|
userId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||||
|
|
@ -1651,7 +1756,7 @@ export class DynamicFormService {
|
||||||
): Promise<{ affectedRows: number }> {
|
): Promise<{ affectedRows: number }> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -1669,11 +1774,13 @@ export class DynamicFormService {
|
||||||
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code')
|
||||||
`;
|
`;
|
||||||
const columnResult = await client.query(columnQuery, [tableName]);
|
const columnResult = await client.query(columnQuery, [tableName]);
|
||||||
const existingColumns = columnResult.rows.map((row: any) => row.column_name);
|
const existingColumns = columnResult.rows.map(
|
||||||
|
(row: any) => row.column_name
|
||||||
const hasUpdatedBy = existingColumns.includes('updated_by');
|
);
|
||||||
const hasUpdatedAt = existingColumns.includes('updated_at');
|
|
||||||
const hasCompanyCode = existingColumns.includes('company_code');
|
const hasUpdatedBy = existingColumns.includes("updated_by");
|
||||||
|
const hasUpdatedAt = existingColumns.includes("updated_at");
|
||||||
|
const hasCompanyCode = existingColumns.includes("company_code");
|
||||||
|
|
||||||
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", {
|
||||||
hasUpdatedBy,
|
hasUpdatedBy,
|
||||||
|
|
@ -1870,7 +1977,8 @@ export class DynamicFormService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
const limitClause = params.limit ? `LIMIT ${params.limit}` : "LIMIT 1000";
|
||||||
|
|
||||||
const sqlQuery = `
|
const sqlQuery = `
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService {
|
||||||
connection_name, description, base_url, endpoint_path, default_headers,
|
connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
default_method, default_request_body,
|
default_method, default_request_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_by
|
company_code, is_active, created_by, save_to_history
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService {
|
||||||
data.company_code || "*",
|
data.company_code || "*",
|
||||||
data.is_active || "Y",
|
data.is_active || "Y",
|
||||||
data.created_by || "system",
|
data.created_by || "system",
|
||||||
|
data.save_to_history || "N",
|
||||||
];
|
];
|
||||||
|
|
||||||
// 디버깅: 저장하려는 데이터 로깅
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
|
|
@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.save_to_history !== undefined) {
|
||||||
|
updateFields.push(`save_to_history = $${paramIndex}`);
|
||||||
|
params.push(data.save_to_history);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.updated_by !== undefined) {
|
if (data.updated_by !== undefined) {
|
||||||
updateFields.push(`updated_by = $${paramIndex}`);
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
params.push(data.updated_by);
|
params.push(data.updated_by);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ export class FlowDataMoveService {
|
||||||
// 내부 DB 처리 (기존 로직)
|
// 내부 DB 처리 (기존 로직)
|
||||||
return await db.transaction(async (client) => {
|
return await db.transaction(async (client) => {
|
||||||
try {
|
try {
|
||||||
|
// 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용)
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId || "system",
|
||||||
|
]);
|
||||||
|
|
||||||
// 1. 단계 정보 조회
|
// 1. 단계 정보 조회
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
@ -684,6 +689,14 @@ export class FlowDataMoveService {
|
||||||
dbConnectionId,
|
dbConnectionId,
|
||||||
async (externalClient, dbType) => {
|
async (externalClient, dbType) => {
|
||||||
try {
|
try {
|
||||||
|
// 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도
|
||||||
|
if (dbType.toLowerCase() === "postgresql") {
|
||||||
|
await externalClient.query(
|
||||||
|
"SELECT set_config('app.user_id', $1, true)",
|
||||||
|
[userId || "system"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 단계 정보 조회 (내부 DB에서)
|
// 1. 단계 정보 조회 (내부 DB에서)
|
||||||
const fromStep = await this.flowStepService.findById(fromStepId);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
const toStep = await this.flowStepService.findById(toStepId);
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
|
||||||
|
|
@ -263,4 +263,139 @@ export class FlowExecutionService {
|
||||||
tableName: result[0].table_name,
|
tableName: result[0].table_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스텝 데이터 업데이트 (인라인 편집)
|
||||||
|
* 원본 테이블의 데이터를 직접 업데이트합니다.
|
||||||
|
*/
|
||||||
|
async updateStepData(
|
||||||
|
flowId: number,
|
||||||
|
stepId: number,
|
||||||
|
recordId: string,
|
||||||
|
updateData: Record<string, any>,
|
||||||
|
userId: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
try {
|
||||||
|
// 1. 플로우 정의 조회
|
||||||
|
const flowDef = await this.flowDefinitionService.findById(flowId);
|
||||||
|
if (!flowDef) {
|
||||||
|
throw new Error(`Flow definition not found: ${flowId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 스텝 조회
|
||||||
|
const step = await this.flowStepService.findById(stepId);
|
||||||
|
if (!step) {
|
||||||
|
throw new Error(`Flow step not found: ${stepId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 테이블명 결정
|
||||||
|
const tableName = step.tableName || flowDef.tableName;
|
||||||
|
if (!tableName) {
|
||||||
|
throw new Error("Table name not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Primary Key 컬럼 결정 (기본값: id)
|
||||||
|
const primaryKeyColumn = flowDef.primaryKey || "id";
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. SET 절 생성
|
||||||
|
const updateColumns = Object.keys(updateData);
|
||||||
|
if (updateColumns.length === 0) {
|
||||||
|
throw new Error("No columns to update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 외부 DB vs 내부 DB 구분
|
||||||
|
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||||
|
// 외부 DB 업데이트
|
||||||
|
console.log(
|
||||||
|
"✅ [updateStepData] Using EXTERNAL DB:",
|
||||||
|
flowDef.dbConnectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connectionResult = await db.query(
|
||||||
|
"SELECT * FROM external_db_connection WHERE id = $1",
|
||||||
|
[flowDef.dbConnectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connectionResult.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`External DB connection not found: ${flowDef.dbConnectionId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionResult[0];
|
||||||
|
const dbType = connection.db_type?.toLowerCase();
|
||||||
|
|
||||||
|
// DB 타입에 따른 placeholder 및 쿼리 생성
|
||||||
|
let setClause: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
|
// MySQL/MariaDB: ? placeholder
|
||||||
|
setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else if (dbType === "mssql") {
|
||||||
|
// MSSQL: @p1, @p2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `[${col}] = @p${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
} else {
|
||||||
|
// PostgreSQL: $1, $2 placeholder
|
||||||
|
setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
params = [...Object.values(updateData), recordId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params);
|
||||||
|
} else {
|
||||||
|
// 내부 DB 업데이트
|
||||||
|
console.log("✅ [updateStepData] Using INTERNAL DB");
|
||||||
|
|
||||||
|
const setClause = updateColumns
|
||||||
|
.map((col, idx) => `"${col}" = $${idx + 1}`)
|
||||||
|
.join(", ");
|
||||||
|
const params = [...Object.values(updateData), recordId];
|
||||||
|
|
||||||
|
const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`;
|
||||||
|
|
||||||
|
console.log(`📝 [updateStepData] Query: ${updateQuery}`);
|
||||||
|
console.log(`📝 [updateStepData] Params:`, params);
|
||||||
|
|
||||||
|
// 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행
|
||||||
|
// (트리거에서 changed_by를 기록하기 위함)
|
||||||
|
await db.transaction(async (client) => {
|
||||||
|
// 안전한 파라미터 바인딩 방식 사용
|
||||||
|
await client.query("SELECT set_config('app.user_id', $1, true)", [
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
await client.query(updateQuery, params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`,
|
||||||
|
{
|
||||||
|
updatedFields: updateColumns,
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [updateStepData] Error:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -607,7 +607,9 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
if (result.rowCount === 0) return null;
|
if (result.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const rule = result.rows[0];
|
const rule = result.rows[0];
|
||||||
|
|
||||||
|
|
@ -897,13 +899,13 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
// 순번 (현재 순번으로 미리보기, 증가 안 함)
|
||||||
const length = autoConfig.sequenceLength || 4;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 4;
|
const length = autoConfig.numberLength || 3;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
@ -957,13 +959,13 @@ class NumberingRuleService {
|
||||||
switch (part.partType) {
|
switch (part.partType) {
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
// 순번 (자동 증가 숫자)
|
// 순번 (자동 증가 숫자)
|
||||||
const length = autoConfig.sequenceLength || 4;
|
const length = autoConfig.sequenceLength || 3;
|
||||||
return String(rule.currentSequence || 1).padStart(length, "0");
|
return String(rule.currentSequence || 1).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
case "number": {
|
case "number": {
|
||||||
// 숫자 (고정 자릿수)
|
// 숫자 (고정 자릿수)
|
||||||
const length = autoConfig.numberLength || 4;
|
const length = autoConfig.numberLength || 3;
|
||||||
const value = autoConfig.numberValue || 1;
|
const value = autoConfig.numberValue || 1;
|
||||||
return String(value).padStart(length, "0");
|
return String(value).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2360,30 +2360,33 @@ export class ScreenManagementService {
|
||||||
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
// 현재 최대 번호 조회
|
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
|
||||||
const existingScreens = await client.query<{ screen_code: string }>(
|
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
|
||||||
`SELECT screen_code FROM screen_definitions
|
const existingScreens = await client.query<{ screen_code: string; num: number }>(
|
||||||
WHERE company_code = $1 AND screen_code LIKE $2
|
`SELECT screen_code,
|
||||||
ORDER BY screen_code DESC
|
COALESCE(
|
||||||
LIMIT 10`,
|
NULLIF(
|
||||||
[companyCode, `${companyCode}%`]
|
regexp_replace(screen_code, $2, '\\1'),
|
||||||
|
screen_code
|
||||||
|
)::integer,
|
||||||
|
0
|
||||||
|
) as num
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND screen_code ~ $2
|
||||||
|
AND deleted_date IS NULL
|
||||||
|
ORDER BY num DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
|
||||||
);
|
);
|
||||||
|
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
const pattern = new RegExp(
|
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
|
||||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
maxNumber = existingScreens.rows[0].num;
|
||||||
);
|
|
||||||
|
|
||||||
for (const screen of existingScreens.rows) {
|
|
||||||
const match = screen.screen_code.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const number = parseInt(match[1], 10);
|
|
||||||
if (number > maxNumber) {
|
|
||||||
maxNumber = number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`);
|
||||||
|
|
||||||
// count개의 코드를 순차적으로 생성
|
// count개의 코드를 순차적으로 생성
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
|
|
||||||
|
|
@ -1258,6 +1258,70 @@ class TableCategoryValueService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 코드로 라벨 조회
|
||||||
|
*
|
||||||
|
* @param valueCodes - 카테고리 코드 배열
|
||||||
|
* @param companyCode - 회사 코드
|
||||||
|
* @returns { [code]: label } 형태의 매핑 객체
|
||||||
|
*/
|
||||||
|
async getCategoryLabelsByCodes(
|
||||||
|
valueCodes: string[],
|
||||||
|
companyCode: string
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
if (!valueCodes || valueCodes.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
// 동적으로 파라미터 플레이스홀더 생성
|
||||||
|
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 카테고리 값 조회
|
||||||
|
query = `
|
||||||
|
SELECT value_code, value_label
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE value_code IN (${placeholders})
|
||||||
|
AND is_active = true
|
||||||
|
`;
|
||||||
|
params = valueCodes;
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||||
|
query = `
|
||||||
|
SELECT value_code, value_label
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE value_code IN (${placeholders})
|
||||||
|
AND is_active = true
|
||||||
|
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
||||||
|
`;
|
||||||
|
params = [...valueCodes, companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
// { [code]: label } 형태로 변환
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
labels[row.value_code] = row.value_label;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TableCategoryValueService();
|
export default new TableCategoryValueService();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,784 @@
|
||||||
|
/**
|
||||||
|
* 세금계산서 서비스
|
||||||
|
* 세금계산서 CRUD 및 비즈니스 로직 처리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, transaction } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// 비용 유형 타입
|
||||||
|
export type CostType = "purchase" | "installation" | "repair" | "maintenance" | "disposal" | "other";
|
||||||
|
|
||||||
|
// 세금계산서 타입 정의
|
||||||
|
export interface TaxInvoice {
|
||||||
|
id: string;
|
||||||
|
company_code: string;
|
||||||
|
invoice_number: string;
|
||||||
|
invoice_type: "sales" | "purchase"; // 매출/매입
|
||||||
|
invoice_status: "draft" | "issued" | "sent" | "cancelled";
|
||||||
|
|
||||||
|
// 공급자 정보
|
||||||
|
supplier_business_no: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_ceo_name: string;
|
||||||
|
supplier_address: string;
|
||||||
|
supplier_business_type: string;
|
||||||
|
supplier_business_item: string;
|
||||||
|
|
||||||
|
// 공급받는자 정보
|
||||||
|
buyer_business_no: string;
|
||||||
|
buyer_name: string;
|
||||||
|
buyer_ceo_name: string;
|
||||||
|
buyer_address: string;
|
||||||
|
buyer_email: string;
|
||||||
|
|
||||||
|
// 금액 정보
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
|
||||||
|
// 날짜 정보
|
||||||
|
invoice_date: string;
|
||||||
|
issue_date: string | null;
|
||||||
|
|
||||||
|
// 기타
|
||||||
|
remarks: string;
|
||||||
|
order_id: string | null;
|
||||||
|
customer_id: string | null;
|
||||||
|
|
||||||
|
// 첨부파일 (JSON 배열로 저장)
|
||||||
|
attachments: TaxInvoiceAttachment[] | null;
|
||||||
|
|
||||||
|
// 비용 유형 (구매/설치/수리/유지보수/폐기/기타)
|
||||||
|
cost_type: CostType | null;
|
||||||
|
|
||||||
|
created_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
writer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 타입
|
||||||
|
export interface TaxInvoiceAttachment {
|
||||||
|
id: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
file_type: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
uploaded_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxInvoiceItem {
|
||||||
|
id: string;
|
||||||
|
tax_invoice_id: string;
|
||||||
|
company_code: string;
|
||||||
|
item_seq: number;
|
||||||
|
item_date: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxInvoiceDto {
|
||||||
|
invoice_type: "sales" | "purchase";
|
||||||
|
supplier_business_no?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
supplier_ceo_name?: string;
|
||||||
|
supplier_address?: string;
|
||||||
|
supplier_business_type?: string;
|
||||||
|
supplier_business_item?: string;
|
||||||
|
buyer_business_no?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
buyer_ceo_name?: string;
|
||||||
|
buyer_address?: string;
|
||||||
|
buyer_email?: string;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
invoice_date: string;
|
||||||
|
remarks?: string;
|
||||||
|
order_id?: string;
|
||||||
|
customer_id?: string;
|
||||||
|
items?: CreateTaxInvoiceItemDto[];
|
||||||
|
attachments?: TaxInvoiceAttachment[]; // 첨부파일
|
||||||
|
cost_type?: CostType; // 비용 유형
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxInvoiceItemDto {
|
||||||
|
item_date?: string;
|
||||||
|
item_name: string;
|
||||||
|
item_spec?: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxInvoiceListParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
invoice_type?: "sales" | "purchase";
|
||||||
|
invoice_status?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
search?: string;
|
||||||
|
buyer_name?: string;
|
||||||
|
cost_type?: CostType; // 비용 유형 필터
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TaxInvoiceService {
|
||||||
|
/**
|
||||||
|
* 세금계산서 번호 채번
|
||||||
|
* 형식: YYYYMM-NNNNN (예: 202512-00001)
|
||||||
|
*/
|
||||||
|
static async generateInvoiceNumber(companyCode: string): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const yearMonth = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const prefix = `${yearMonth}-`;
|
||||||
|
|
||||||
|
// 해당 월의 마지막 번호 조회
|
||||||
|
const result = await query<{ max_num: string }>(
|
||||||
|
`SELECT invoice_number as max_num
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND invoice_number LIKE $2
|
||||||
|
ORDER BY invoice_number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[companyCode, `${prefix}%`]
|
||||||
|
);
|
||||||
|
|
||||||
|
let nextNum = 1;
|
||||||
|
if (result.length > 0 && result[0].max_num) {
|
||||||
|
const lastNum = parseInt(result[0].max_num.split("-")[1], 10);
|
||||||
|
nextNum = lastNum + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${String(nextNum).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 목록 조회
|
||||||
|
*/
|
||||||
|
static async getList(
|
||||||
|
companyCode: string,
|
||||||
|
params: TaxInvoiceListParams
|
||||||
|
): Promise<{ data: TaxInvoice[]; total: number; page: number; pageSize: number }> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
invoice_type,
|
||||||
|
invoice_status,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
search,
|
||||||
|
buyer_name,
|
||||||
|
cost_type,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const values: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (invoice_type) {
|
||||||
|
conditions.push(`invoice_type = $${paramIndex}`);
|
||||||
|
values.push(invoice_type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_status) {
|
||||||
|
conditions.push(`invoice_status = $${paramIndex}`);
|
||||||
|
values.push(invoice_status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start_date) {
|
||||||
|
conditions.push(`invoice_date >= $${paramIndex}`);
|
||||||
|
values.push(start_date);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end_date) {
|
||||||
|
conditions.push(`invoice_date <= $${paramIndex}`);
|
||||||
|
values.push(end_date);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(invoice_number ILIKE $${paramIndex} OR buyer_name ILIKE $${paramIndex} OR supplier_name ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
|
values.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buyer_name) {
|
||||||
|
conditions.push(`buyer_name ILIKE $${paramIndex}`);
|
||||||
|
values.push(`%${buyer_name}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cost_type) {
|
||||||
|
conditions.push(`cost_type = $${paramIndex}`);
|
||||||
|
values.push(cost_type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countResult = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM tax_invoice WHERE ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult[0]?.count || "0", 10);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
values.push(pageSize, offset);
|
||||||
|
const data = await query<TaxInvoice>(
|
||||||
|
`SELECT * FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY created_date DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return { data, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 상세 조회 (품목 포함)
|
||||||
|
*/
|
||||||
|
static async getById(
|
||||||
|
id: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<{ invoice: TaxInvoice; items: TaxInvoiceItem[] } | null> {
|
||||||
|
const invoiceResult = await query<TaxInvoice>(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoiceResult.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await query<TaxInvoiceItem>(
|
||||||
|
`SELECT * FROM tax_invoice_item
|
||||||
|
WHERE tax_invoice_id = $1 AND company_code = $2
|
||||||
|
ORDER BY item_seq`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { invoice: invoiceResult[0], items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 생성
|
||||||
|
*/
|
||||||
|
static async create(
|
||||||
|
data: CreateTaxInvoiceDto,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<TaxInvoice> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 세금계산서 번호 채번
|
||||||
|
const invoiceNumber = await this.generateInvoiceNumber(companyCode);
|
||||||
|
|
||||||
|
// 세금계산서 생성
|
||||||
|
const invoiceResult = await client.query(
|
||||||
|
`INSERT INTO tax_invoice (
|
||||||
|
company_code, invoice_number, invoice_type, invoice_status,
|
||||||
|
supplier_business_no, supplier_name, supplier_ceo_name, supplier_address,
|
||||||
|
supplier_business_type, supplier_business_item,
|
||||||
|
buyer_business_no, buyer_name, buyer_ceo_name, buyer_address, buyer_email,
|
||||||
|
supply_amount, tax_amount, total_amount, invoice_date,
|
||||||
|
remarks, order_id, customer_id, attachments, cost_type, writer
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, 'draft',
|
||||||
|
$4, $5, $6, $7, $8, $9,
|
||||||
|
$10, $11, $12, $13, $14,
|
||||||
|
$15, $16, $17, $18,
|
||||||
|
$19, $20, $21, $22, $23, $24
|
||||||
|
) RETURNING *`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
invoiceNumber,
|
||||||
|
data.invoice_type,
|
||||||
|
data.supplier_business_no || null,
|
||||||
|
data.supplier_name || null,
|
||||||
|
data.supplier_ceo_name || null,
|
||||||
|
data.supplier_address || null,
|
||||||
|
data.supplier_business_type || null,
|
||||||
|
data.supplier_business_item || null,
|
||||||
|
data.buyer_business_no || null,
|
||||||
|
data.buyer_name || null,
|
||||||
|
data.buyer_ceo_name || null,
|
||||||
|
data.buyer_address || null,
|
||||||
|
data.buyer_email || null,
|
||||||
|
data.supply_amount,
|
||||||
|
data.tax_amount,
|
||||||
|
data.total_amount,
|
||||||
|
data.invoice_date,
|
||||||
|
data.remarks || null,
|
||||||
|
data.order_id || null,
|
||||||
|
data.customer_id || null,
|
||||||
|
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||||
|
data.cost_type || null,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
|
||||||
|
// 품목 생성
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const item = data.items[i];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tax_invoice_item (
|
||||||
|
tax_invoice_id, company_code, item_seq,
|
||||||
|
item_date, item_name, item_spec, quantity, unit_price,
|
||||||
|
supply_amount, tax_amount, remarks
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
invoice.id,
|
||||||
|
companyCode,
|
||||||
|
i + 1,
|
||||||
|
item.item_date || null,
|
||||||
|
item.item_name,
|
||||||
|
item.item_spec || null,
|
||||||
|
item.quantity,
|
||||||
|
item.unit_price,
|
||||||
|
item.supply_amount,
|
||||||
|
item.tax_amount,
|
||||||
|
item.remarks || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 생성 완료", {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 수정
|
||||||
|
*/
|
||||||
|
static async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<CreateTaxInvoiceDto>,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<TaxInvoice | null> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 기존 세금계산서 확인
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 발행된 세금계산서는 수정 불가
|
||||||
|
if (existing.rows[0].invoice_status !== "draft") {
|
||||||
|
throw new Error("발행된 세금계산서는 수정할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세금계산서 수정
|
||||||
|
const updateResult = await client.query(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
supplier_business_no = COALESCE($3, supplier_business_no),
|
||||||
|
supplier_name = COALESCE($4, supplier_name),
|
||||||
|
supplier_ceo_name = COALESCE($5, supplier_ceo_name),
|
||||||
|
supplier_address = COALESCE($6, supplier_address),
|
||||||
|
supplier_business_type = COALESCE($7, supplier_business_type),
|
||||||
|
supplier_business_item = COALESCE($8, supplier_business_item),
|
||||||
|
buyer_business_no = COALESCE($9, buyer_business_no),
|
||||||
|
buyer_name = COALESCE($10, buyer_name),
|
||||||
|
buyer_ceo_name = COALESCE($11, buyer_ceo_name),
|
||||||
|
buyer_address = COALESCE($12, buyer_address),
|
||||||
|
buyer_email = COALESCE($13, buyer_email),
|
||||||
|
supply_amount = COALESCE($14, supply_amount),
|
||||||
|
tax_amount = COALESCE($15, tax_amount),
|
||||||
|
total_amount = COALESCE($16, total_amount),
|
||||||
|
invoice_date = COALESCE($17, invoice_date),
|
||||||
|
remarks = COALESCE($18, remarks),
|
||||||
|
attachments = $19,
|
||||||
|
cost_type = COALESCE($20, cost_type),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
data.supplier_business_no,
|
||||||
|
data.supplier_name,
|
||||||
|
data.supplier_ceo_name,
|
||||||
|
data.supplier_address,
|
||||||
|
data.supplier_business_type,
|
||||||
|
data.supplier_business_item,
|
||||||
|
data.buyer_business_no,
|
||||||
|
data.buyer_name,
|
||||||
|
data.buyer_ceo_name,
|
||||||
|
data.buyer_address,
|
||||||
|
data.buyer_email,
|
||||||
|
data.supply_amount,
|
||||||
|
data.tax_amount,
|
||||||
|
data.total_amount,
|
||||||
|
data.invoice_date,
|
||||||
|
data.remarks,
|
||||||
|
data.attachments ? JSON.stringify(data.attachments) : null,
|
||||||
|
data.cost_type,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 품목 업데이트 (기존 삭제 후 재생성)
|
||||||
|
if (data.items) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const item = data.items[i];
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO tax_invoice_item (
|
||||||
|
tax_invoice_id, company_code, item_seq,
|
||||||
|
item_date, item_name, item_spec, quantity, unit_price,
|
||||||
|
supply_amount, tax_amount, remarks
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
i + 1,
|
||||||
|
item.item_date || null,
|
||||||
|
item.item_name,
|
||||||
|
item.item_spec || null,
|
||||||
|
item.quantity,
|
||||||
|
item.unit_price,
|
||||||
|
item.supply_amount,
|
||||||
|
item.tax_amount,
|
||||||
|
item.remarks || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 수정 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return updateResult.rows[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 삭제
|
||||||
|
*/
|
||||||
|
static async delete(id: string, companyCode: string, userId: string): Promise<boolean> {
|
||||||
|
return await transaction(async (client) => {
|
||||||
|
// 기존 세금계산서 확인
|
||||||
|
const existing = await client.query(
|
||||||
|
`SELECT * FROM tax_invoice WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 발행된 세금계산서는 삭제 불가
|
||||||
|
if (existing.rows[0].invoice_status !== "draft") {
|
||||||
|
throw new Error("발행된 세금계산서는 삭제할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM tax_invoice_item WHERE tax_invoice_id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 세금계산서 삭제
|
||||||
|
await client.query(`DELETE FROM tax_invoice WHERE id = $1 AND company_code = $2`, [
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("세금계산서 삭제 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 발행 (상태 변경)
|
||||||
|
*/
|
||||||
|
static async issue(id: string, companyCode: string, userId: string): Promise<TaxInvoice | null> {
|
||||||
|
const result = await query<TaxInvoice>(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
invoice_status = 'issued',
|
||||||
|
issue_date = NOW(),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND invoice_status = 'draft'
|
||||||
|
RETURNING *`,
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 발행 완료", { invoiceId: id, companyCode, userId });
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세금계산서 취소
|
||||||
|
*/
|
||||||
|
static async cancel(
|
||||||
|
id: string,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string,
|
||||||
|
reason?: string
|
||||||
|
): Promise<TaxInvoice | null> {
|
||||||
|
const result = await query<TaxInvoice>(
|
||||||
|
`UPDATE tax_invoice SET
|
||||||
|
invoice_status = 'cancelled',
|
||||||
|
remarks = CASE WHEN $3 IS NOT NULL THEN remarks || ' [취소사유: ' || $3 || ']' ELSE remarks END,
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $1 AND company_code = $2 AND invoice_status IN ('draft', 'issued')
|
||||||
|
RETURNING *`,
|
||||||
|
[id, companyCode, reason || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("세금계산서 취소 완료", { invoiceId: id, companyCode, userId, reason });
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월별 통계 조회
|
||||||
|
*/
|
||||||
|
static async getMonthlyStats(
|
||||||
|
companyCode: string,
|
||||||
|
year: number,
|
||||||
|
month: number
|
||||||
|
): Promise<{
|
||||||
|
sales: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
purchase: { count: number; supply_amount: number; tax_amount: number; total_amount: number };
|
||||||
|
}> {
|
||||||
|
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||||
|
const endDate = new Date(year, month, 0).toISOString().split("T")[0]; // 해당 월 마지막 날
|
||||||
|
|
||||||
|
const result = await query<{
|
||||||
|
invoice_type: string;
|
||||||
|
count: string;
|
||||||
|
supply_amount: string;
|
||||||
|
tax_amount: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
invoice_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND invoice_date >= $2
|
||||||
|
AND invoice_date <= $3
|
||||||
|
AND invoice_status != 'cancelled'
|
||||||
|
GROUP BY invoice_type`,
|
||||||
|
[companyCode, startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
sales: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||||
|
purchase: { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of result) {
|
||||||
|
const type = row.invoice_type as "sales" | "purchase";
|
||||||
|
stats[type] = {
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
supply_amount: parseFloat(row.supply_amount),
|
||||||
|
tax_amount: parseFloat(row.tax_amount),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비용 유형별 통계 조회
|
||||||
|
*/
|
||||||
|
static async getCostTypeStats(
|
||||||
|
companyCode: string,
|
||||||
|
year?: number,
|
||||||
|
month?: number
|
||||||
|
): Promise<{
|
||||||
|
by_cost_type: Array<{
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
supply_amount: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
by_month: Array<{
|
||||||
|
year_month: string;
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: number;
|
||||||
|
total_amount: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
total_count: number;
|
||||||
|
total_amount: number;
|
||||||
|
purchase_amount: number;
|
||||||
|
installation_amount: number;
|
||||||
|
repair_amount: number;
|
||||||
|
maintenance_amount: number;
|
||||||
|
disposal_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const conditions: string[] = ["company_code = $1", "invoice_status != 'cancelled'"];
|
||||||
|
const values: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 연도/월 필터
|
||||||
|
if (year && month) {
|
||||||
|
const startDate = `${year}-${String(month).padStart(2, "0")}-01`;
|
||||||
|
const endDate = new Date(year, month, 0).toISOString().split("T")[0];
|
||||||
|
conditions.push(`invoice_date >= $${paramIndex} AND invoice_date <= $${paramIndex + 1}`);
|
||||||
|
values.push(startDate, endDate);
|
||||||
|
paramIndex += 2;
|
||||||
|
} else if (year) {
|
||||||
|
conditions.push(`EXTRACT(YEAR FROM invoice_date) = $${paramIndex}`);
|
||||||
|
values.push(year);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// 비용 유형별 집계
|
||||||
|
const byCostType = await query<{
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: string;
|
||||||
|
supply_amount: string;
|
||||||
|
tax_amount: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
cost_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(supply_amount), 0) as supply_amount,
|
||||||
|
COALESCE(SUM(tax_amount), 0) as tax_amount,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY cost_type
|
||||||
|
ORDER BY total_amount DESC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 월별 비용 유형 집계
|
||||||
|
const byMonth = await query<{
|
||||||
|
year_month: string;
|
||||||
|
cost_type: CostType | null;
|
||||||
|
count: string;
|
||||||
|
total_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(invoice_date, 'YYYY-MM') as year_month,
|
||||||
|
cost_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY TO_CHAR(invoice_date, 'YYYY-MM'), cost_type
|
||||||
|
ORDER BY year_month DESC, cost_type`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 요약
|
||||||
|
const summaryResult = await query<{
|
||||||
|
total_count: string;
|
||||||
|
total_amount: string;
|
||||||
|
purchase_amount: string;
|
||||||
|
installation_amount: string;
|
||||||
|
repair_amount: string;
|
||||||
|
maintenance_amount: string;
|
||||||
|
disposal_amount: string;
|
||||||
|
other_amount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'purchase' THEN total_amount ELSE 0 END), 0) as purchase_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'installation' THEN total_amount ELSE 0 END), 0) as installation_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'repair' THEN total_amount ELSE 0 END), 0) as repair_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'maintenance' THEN total_amount ELSE 0 END), 0) as maintenance_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'disposal' THEN total_amount ELSE 0 END), 0) as disposal_amount,
|
||||||
|
COALESCE(SUM(CASE WHEN cost_type = 'other' THEN total_amount ELSE 0 END), 0) as other_amount
|
||||||
|
FROM tax_invoice
|
||||||
|
WHERE ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = summaryResult[0] || {
|
||||||
|
total_count: "0",
|
||||||
|
total_amount: "0",
|
||||||
|
purchase_amount: "0",
|
||||||
|
installation_amount: "0",
|
||||||
|
repair_amount: "0",
|
||||||
|
maintenance_amount: "0",
|
||||||
|
disposal_amount: "0",
|
||||||
|
other_amount: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
by_cost_type: byCostType.map((row) => ({
|
||||||
|
cost_type: row.cost_type,
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
supply_amount: parseFloat(row.supply_amount),
|
||||||
|
tax_amount: parseFloat(row.tax_amount),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
})),
|
||||||
|
by_month: byMonth.map((row) => ({
|
||||||
|
year_month: row.year_month,
|
||||||
|
cost_type: row.cost_type,
|
||||||
|
count: parseInt(row.count, 10),
|
||||||
|
total_amount: parseFloat(row.total_amount),
|
||||||
|
})),
|
||||||
|
summary: {
|
||||||
|
total_count: parseInt(summary.total_count, 10),
|
||||||
|
total_amount: parseFloat(summary.total_amount),
|
||||||
|
purchase_amount: parseFloat(summary.purchase_amount),
|
||||||
|
installation_amount: parseFloat(summary.installation_amount),
|
||||||
|
repair_amount: parseFloat(summary.repair_amount),
|
||||||
|
maintenance_amount: parseFloat(summary.maintenance_amount),
|
||||||
|
disposal_amount: parseFloat(summary.disposal_amount),
|
||||||
|
other_amount: parseFloat(summary.other_amount),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ export interface ExternalRestApiConnection {
|
||||||
retry_delay?: number;
|
retry_delay?: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
is_active: string;
|
is_active: string;
|
||||||
|
|
||||||
|
// 위치 이력 저장 설정 (지도 위젯용)
|
||||||
|
save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,238 @@
|
||||||
|
# 마이그레이션 063-064: 재고 관리 테이블 생성
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
재고 현황 관리 및 입출고 이력 추적을 위한 테이블 생성
|
||||||
|
|
||||||
|
**테이블 타입관리 UI와 동일한 방식으로 생성됩니다.**
|
||||||
|
|
||||||
|
### 생성되는 테이블
|
||||||
|
|
||||||
|
| 테이블명 | 설명 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `inventory_stock` | 재고 현황 | 품목+로트별 현재 재고 상태 |
|
||||||
|
| `inventory_history` | 재고 이력 | 입출고 트랜잭션 기록 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 타입관리 UI 방식 특징
|
||||||
|
|
||||||
|
1. **기본 컬럼 자동 포함**: `id`, `created_date`, `updated_date`, `writer`, `company_code`
|
||||||
|
2. **데이터 타입 통일**: 날짜는 `TIMESTAMP`, 나머지는 `VARCHAR(500)`
|
||||||
|
3. **메타데이터 등록**:
|
||||||
|
- `table_labels`: 테이블 정보
|
||||||
|
- `column_labels`: 컬럼 정보 (라벨, input_type, detail_settings)
|
||||||
|
- `table_type_columns`: 회사별 컬럼 타입 정보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 구조
|
||||||
|
|
||||||
|
### 1. inventory_stock (재고 현황)
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | input_type | 설명 |
|
||||||
|
|--------|------|------------|------|
|
||||||
|
| id | VARCHAR(500) | text | PK (자동생성) |
|
||||||
|
| created_date | TIMESTAMP | date | 생성일시 |
|
||||||
|
| updated_date | TIMESTAMP | date | 수정일시 |
|
||||||
|
| writer | VARCHAR(500) | text | 작성자 |
|
||||||
|
| company_code | VARCHAR(500) | text | 회사코드 |
|
||||||
|
| item_code | VARCHAR(500) | text | 품목코드 |
|
||||||
|
| lot_number | VARCHAR(500) | text | 로트번호 |
|
||||||
|
| warehouse_id | VARCHAR(500) | entity | 창고 (FK → warehouse_info) |
|
||||||
|
| location_code | VARCHAR(500) | text | 위치코드 |
|
||||||
|
| current_qty | VARCHAR(500) | number | 현재고량 |
|
||||||
|
| safety_qty | VARCHAR(500) | number | 안전재고 |
|
||||||
|
| last_in_date | TIMESTAMP | date | 최종입고일 |
|
||||||
|
| last_out_date | TIMESTAMP | date | 최종출고일 |
|
||||||
|
|
||||||
|
### 2. inventory_history (재고 이력)
|
||||||
|
|
||||||
|
| 컬럼명 | 타입 | input_type | 설명 |
|
||||||
|
|--------|------|------------|------|
|
||||||
|
| id | VARCHAR(500) | text | PK (자동생성) |
|
||||||
|
| created_date | TIMESTAMP | date | 생성일시 |
|
||||||
|
| updated_date | TIMESTAMP | date | 수정일시 |
|
||||||
|
| writer | VARCHAR(500) | text | 작성자 |
|
||||||
|
| company_code | VARCHAR(500) | text | 회사코드 |
|
||||||
|
| stock_id | VARCHAR(500) | text | 재고ID (FK) |
|
||||||
|
| item_code | VARCHAR(500) | text | 품목코드 |
|
||||||
|
| lot_number | VARCHAR(500) | text | 로트번호 |
|
||||||
|
| transaction_type | VARCHAR(500) | code | 구분 (IN/OUT) |
|
||||||
|
| transaction_date | TIMESTAMP | date | 일자 |
|
||||||
|
| quantity | VARCHAR(500) | number | 수량 |
|
||||||
|
| balance_qty | VARCHAR(500) | number | 재고량 |
|
||||||
|
| manager_id | VARCHAR(500) | text | 담당자ID |
|
||||||
|
| manager_name | VARCHAR(500) | text | 담당자명 |
|
||||||
|
| remark | VARCHAR(500) | text | 비고 |
|
||||||
|
| reference_type | VARCHAR(500) | text | 참조문서유형 |
|
||||||
|
| reference_id | VARCHAR(500) | text | 참조문서ID |
|
||||||
|
| reference_number | VARCHAR(500) | text | 참조문서번호 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 실행 방법
|
||||||
|
|
||||||
|
### Docker 환경 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 재고 현황 테이블
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/063_create_inventory_stock.sql
|
||||||
|
|
||||||
|
# 재고 이력 테이블
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/migrations/064_create_inventory_history.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 로컬 PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/063_create_inventory_stock.sql
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/064_create_inventory_history.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### pgAdmin / DBeaver
|
||||||
|
|
||||||
|
1. 각 SQL 파일 열기
|
||||||
|
2. 전체 내용 복사
|
||||||
|
3. SQL 쿼리 창에 붙여넣기
|
||||||
|
4. 실행 (F5 또는 Execute)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 방법
|
||||||
|
|
||||||
|
### 1. 테이블 생성 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 메타데이터 등록 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- table_labels
|
||||||
|
SELECT * FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
|
||||||
|
-- column_labels
|
||||||
|
SELECT table_name, column_name, column_label, input_type, display_order
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history')
|
||||||
|
ORDER BY table_name, display_order;
|
||||||
|
|
||||||
|
-- table_type_columns
|
||||||
|
SELECT table_name, column_name, company_code, input_type, display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name IN ('inventory_stock', 'inventory_history')
|
||||||
|
ORDER BY table_name, display_order;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 샘플 데이터 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 재고 현황
|
||||||
|
SELECT * FROM inventory_stock WHERE company_code = 'WACE';
|
||||||
|
|
||||||
|
-- 재고 이력
|
||||||
|
SELECT * FROM inventory_history WHERE company_code = 'WACE' ORDER BY transaction_date;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 화면에서 사용할 조회 쿼리 예시
|
||||||
|
|
||||||
|
### 재고 현황 그리드 (좌측)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
s.item_code,
|
||||||
|
i.item_name,
|
||||||
|
i.size as specification,
|
||||||
|
i.unit,
|
||||||
|
s.lot_number,
|
||||||
|
w.warehouse_name,
|
||||||
|
s.location_code,
|
||||||
|
s.current_qty::numeric as current_qty,
|
||||||
|
s.safety_qty::numeric as safety_qty,
|
||||||
|
CASE
|
||||||
|
WHEN s.current_qty::numeric < s.safety_qty::numeric THEN '부족'
|
||||||
|
WHEN s.current_qty::numeric > s.safety_qty::numeric * 2 THEN '과다'
|
||||||
|
ELSE '정상'
|
||||||
|
END AS stock_status,
|
||||||
|
s.last_in_date,
|
||||||
|
s.last_out_date
|
||||||
|
FROM inventory_stock s
|
||||||
|
LEFT JOIN item_info i ON s.item_code = i.item_number AND s.company_code = i.company_code
|
||||||
|
LEFT JOIN warehouse_info w ON s.warehouse_id = w.id
|
||||||
|
WHERE s.company_code = 'WACE'
|
||||||
|
ORDER BY s.item_code, s.lot_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 재고 이력 패널 (우측)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
h.transaction_type,
|
||||||
|
h.transaction_date,
|
||||||
|
h.quantity,
|
||||||
|
h.balance_qty,
|
||||||
|
h.manager_name,
|
||||||
|
h.remark
|
||||||
|
FROM inventory_history h
|
||||||
|
WHERE h.item_code = 'A001'
|
||||||
|
AND h.lot_number = 'LOT-2024-001'
|
||||||
|
AND h.company_code = 'WACE'
|
||||||
|
ORDER BY h.transaction_date DESC, h.created_date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[입고 발생]
|
||||||
|
│
|
||||||
|
├─→ inventory_history에 INSERT (+수량, 잔량)
|
||||||
|
│
|
||||||
|
└─→ inventory_stock에 UPDATE (current_qty 증가, last_in_date 갱신)
|
||||||
|
|
||||||
|
[출고 발생]
|
||||||
|
│
|
||||||
|
├─→ inventory_history에 INSERT (-수량, 잔량)
|
||||||
|
│
|
||||||
|
└─→ inventory_stock에 UPDATE (current_qty 감소, last_out_date 갱신)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤백 방법 (문제 발생 시)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 테이블 삭제
|
||||||
|
DROP TABLE IF EXISTS inventory_history;
|
||||||
|
DROP TABLE IF EXISTS inventory_stock;
|
||||||
|
|
||||||
|
-- 메타데이터 삭제
|
||||||
|
DELETE FROM column_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
DELETE FROM table_labels WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
DELETE FROM table_type_columns WHERE table_name IN ('inventory_stock', 'inventory_history');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 테이블 (마스터 데이터)
|
||||||
|
|
||||||
|
| 테이블 | 역할 | 연결 컬럼 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| item_info | 품목 마스터 | item_number |
|
||||||
|
| warehouse_info | 창고 마스터 | id |
|
||||||
|
| warehouse_location | 위치 마스터 | location_code |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-12-09
|
||||||
|
**영향 범위**: 재고 관리 시스템
|
||||||
|
**생성 방식**: 테이블 타입관리 UI와 동일
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
# 065 마이그레이션 실행 가이드
|
||||||
|
|
||||||
|
## 연쇄 드롭다운 관계 관리 테이블 생성
|
||||||
|
|
||||||
|
### 실행 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 환경
|
||||||
|
psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql
|
||||||
|
|
||||||
|
# Docker 환경
|
||||||
|
docker exec -i <postgres_container> psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql
|
||||||
|
|
||||||
|
# 또는 DBeaver/pgAdmin에서 직접 실행
|
||||||
|
```
|
||||||
|
|
||||||
|
### 생성되는 테이블
|
||||||
|
|
||||||
|
- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블
|
||||||
|
|
||||||
|
### 샘플 데이터
|
||||||
|
|
||||||
|
마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다.
|
||||||
|
|
||||||
|
### 확인 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM cascading_relation;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
@ -0,0 +1,584 @@
|
||||||
|
# 노드 플로우 기능 개선 사항
|
||||||
|
|
||||||
|
> 작성일: 2024-12-08
|
||||||
|
> 상태: 분석 완료, 개선 대기
|
||||||
|
|
||||||
|
## 현재 구현 상태
|
||||||
|
|
||||||
|
### 잘 구현된 기능
|
||||||
|
|
||||||
|
| 기능 | 상태 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 위상 정렬 실행 | 완료 | DAG 기반 레벨별 실행 |
|
||||||
|
| 트랜잭션 관리 | 완료 | 전체 플로우 단일 트랜잭션, 실패 시 자동 롤백 |
|
||||||
|
| 병렬 실행 | 완료 | 같은 레벨 노드 `Promise.allSettled`로 병렬 처리 |
|
||||||
|
| CRUD 액션 | 완료 | INSERT, UPDATE, DELETE, UPSERT 지원 |
|
||||||
|
| 외부 DB 연동 | 완료 | PostgreSQL, MySQL, MSSQL, Oracle 지원 |
|
||||||
|
| REST API 연동 | 완료 | GET, POST, PUT, DELETE 지원 |
|
||||||
|
| 조건 분기 | 완료 | 다양한 연산자 지원 |
|
||||||
|
| 데이터 변환 | 부분 완료 | UPPERCASE, TRIM, EXPLODE 등 기본 변환 |
|
||||||
|
| 집계 함수 | 완료 | SUM, COUNT, AVG, MIN, MAX, FIRST, LAST |
|
||||||
|
|
||||||
|
### 관련 파일
|
||||||
|
|
||||||
|
- **백엔드 실행 엔진**: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||||
|
- **백엔드 라우트**: `backend-node/src/routes/dataflow/node-flows.ts`
|
||||||
|
- **프론트엔드 API**: `frontend/lib/api/nodeFlows.ts`
|
||||||
|
- **프론트엔드 에디터**: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||||
|
- **타입 정의**: `backend-node/src/types/flow.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개선 필요 사항
|
||||||
|
|
||||||
|
### 1. [우선순위 높음] 실행 이력 로깅
|
||||||
|
|
||||||
|
**현재 상태**: 플로우 실행 이력이 저장되지 않음
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 언제, 누가, 어떤 플로우를 실행했는지 추적 불가
|
||||||
|
- 실패 원인 분석 어려움
|
||||||
|
- 감사(Audit) 요구사항 충족 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- db/migrations/XXX_add_node_flow_execution_log.sql
|
||||||
|
CREATE TABLE node_flow_execution_log (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||||
|
execution_status VARCHAR(20) NOT NULL, -- 'success', 'failed', 'partial'
|
||||||
|
execution_time_ms INTEGER,
|
||||||
|
total_nodes INTEGER,
|
||||||
|
success_nodes INTEGER,
|
||||||
|
failed_nodes INTEGER,
|
||||||
|
skipped_nodes INTEGER,
|
||||||
|
executed_by VARCHAR(50),
|
||||||
|
company_code VARCHAR(20),
|
||||||
|
context_data JSONB,
|
||||||
|
result_summary JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_flow_execution_log_flow_id ON node_flow_execution_log(flow_id);
|
||||||
|
CREATE INDEX idx_flow_execution_log_created_at ON node_flow_execution_log(created_at DESC);
|
||||||
|
CREATE INDEX idx_flow_execution_log_company_code ON node_flow_execution_log(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] 마이그레이션 파일 생성
|
||||||
|
- [ ] `nodeFlowExecutionService.ts`에 로그 저장 로직 추가
|
||||||
|
- [ ] 실행 이력 조회 API 추가 (`GET /api/dataflow/node-flows/:flowId/executions`)
|
||||||
|
- [ ] 프론트엔드 실행 이력 UI 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. [우선순위 높음] 드라이런(Dry Run) 모드
|
||||||
|
|
||||||
|
**현재 상태**: 실제 데이터를 변경하지 않고 테스트할 방법 없음
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 프로덕션 데이터에 직접 영향
|
||||||
|
- 플로우 디버깅 어려움
|
||||||
|
- 신규 플로우 검증 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nodeFlowExecutionService.ts
|
||||||
|
static async executeFlow(
|
||||||
|
flowId: number,
|
||||||
|
contextData: Record<string, any>,
|
||||||
|
options: { dryRun?: boolean } = {}
|
||||||
|
): Promise<ExecutionResult> {
|
||||||
|
if (options.dryRun) {
|
||||||
|
// 트랜잭션 시작 후 항상 롤백
|
||||||
|
return transaction(async (client) => {
|
||||||
|
const result = await this.executeFlowInternal(flowId, contextData, client);
|
||||||
|
// 롤백을 위해 의도적으로 에러 발생
|
||||||
|
throw new DryRunComplete(result);
|
||||||
|
}).catch((e) => {
|
||||||
|
if (e instanceof DryRunComplete) {
|
||||||
|
return { ...e.result, dryRun: true };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 기존 로직...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// node-flows.ts 라우트 수정
|
||||||
|
router.post("/:flowId/execute", async (req, res) => {
|
||||||
|
const dryRun = req.query.dryRun === 'true';
|
||||||
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flowId, 10),
|
||||||
|
enrichedContextData,
|
||||||
|
{ dryRun }
|
||||||
|
);
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] `DryRunComplete` 예외 클래스 생성
|
||||||
|
- [ ] `executeFlow` 메서드에 `dryRun` 옵션 추가
|
||||||
|
- [ ] 라우트에 쿼리 파라미터 처리 추가
|
||||||
|
- [ ] 프론트엔드 "테스트 실행" 버튼 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. [우선순위 높음] 재시도 메커니즘
|
||||||
|
|
||||||
|
**현재 상태**: 외부 API/DB 호출 실패 시 재시도 없음
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 일시적 네트워크 오류로 전체 플로우 실패
|
||||||
|
- 외부 서비스 불안정 시 신뢰성 저하
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// utils/retry.ts
|
||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
maxRetries?: number;
|
||||||
|
delay?: number;
|
||||||
|
backoffMultiplier?: number;
|
||||||
|
retryOn?: (error: any) => boolean;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
delay = 1000,
|
||||||
|
backoffMultiplier = 2,
|
||||||
|
retryOn = () => true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === maxRetries - 1 || !retryOn(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const waitTime = delay * Math.pow(backoffMultiplier, attempt);
|
||||||
|
logger.warn(`재시도 ${attempt + 1}/${maxRetries}, ${waitTime}ms 후...`);
|
||||||
|
await new Promise(r => setTimeout(r, waitTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('재시도 횟수 초과');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nodeFlowExecutionService.ts에서 사용
|
||||||
|
const response = await withRetry(
|
||||||
|
() => axios({ method, url, headers, data, timeout }),
|
||||||
|
{
|
||||||
|
maxRetries: 3,
|
||||||
|
delay: 1000,
|
||||||
|
retryOn: (err) => err.code === 'ECONNRESET' || err.response?.status >= 500
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] `withRetry` 유틸리티 함수 생성
|
||||||
|
- [ ] REST API 호출 부분에 재시도 로직 적용
|
||||||
|
- [ ] 외부 DB 연결 부분에 재시도 로직 적용
|
||||||
|
- [ ] 노드별 재시도 설정 UI 추가 (선택사항)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. [우선순위 높음] 미완성 데이터 변환 함수
|
||||||
|
|
||||||
|
**현재 상태**: FORMAT, CALCULATE, JSON_EXTRACT, CUSTOM 변환이 미구현
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 날짜/숫자 포맷팅 불가
|
||||||
|
- 계산식 처리 불가
|
||||||
|
- JSON 데이터 파싱 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nodeFlowExecutionService.ts - applyTransformation 메서드 수정
|
||||||
|
|
||||||
|
case "FORMAT":
|
||||||
|
return rows.map((row) => {
|
||||||
|
const value = row[sourceField];
|
||||||
|
let formatted = value;
|
||||||
|
|
||||||
|
if (transform.formatType === 'date') {
|
||||||
|
// dayjs 사용
|
||||||
|
formatted = dayjs(value).format(transform.formatPattern || 'YYYY-MM-DD');
|
||||||
|
} else if (transform.formatType === 'number') {
|
||||||
|
// 숫자 포맷팅
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (transform.formatPattern === 'currency') {
|
||||||
|
formatted = num.toLocaleString('ko-KR', { style: 'currency', currency: 'KRW' });
|
||||||
|
} else if (transform.formatPattern === 'percent') {
|
||||||
|
formatted = (num * 100).toFixed(transform.decimals || 0) + '%';
|
||||||
|
} else {
|
||||||
|
formatted = num.toLocaleString('ko-KR', { maximumFractionDigits: transform.decimals || 2 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...row, [actualTargetField]: formatted };
|
||||||
|
});
|
||||||
|
|
||||||
|
case "CALCULATE":
|
||||||
|
return rows.map((row) => {
|
||||||
|
// 간단한 수식 평가 (보안 주의!)
|
||||||
|
const expression = transform.expression; // 예: "price * quantity"
|
||||||
|
const result = evaluateExpression(expression, row);
|
||||||
|
return { ...row, [actualTargetField]: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
case "JSON_EXTRACT":
|
||||||
|
return rows.map((row) => {
|
||||||
|
const jsonValue = typeof row[sourceField] === 'string'
|
||||||
|
? JSON.parse(row[sourceField])
|
||||||
|
: row[sourceField];
|
||||||
|
const extracted = jsonPath.query(jsonValue, transform.jsonPath); // JSONPath 라이브러리 사용
|
||||||
|
return { ...row, [actualTargetField]: extracted[0] || null };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] `dayjs` 라이브러리 추가 (날짜 포맷팅)
|
||||||
|
- [ ] `jsonpath` 라이브러리 추가 (JSON 추출)
|
||||||
|
- [ ] 안전한 수식 평가 함수 구현 (eval 대신)
|
||||||
|
- [ ] 각 변환 타입별 UI 설정 패널 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. [우선순위 중간] 플로우 버전 관리
|
||||||
|
|
||||||
|
**현재 상태**: 플로우 수정 시 이전 버전 덮어씀
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 실수로 수정한 플로우 복구 불가
|
||||||
|
- 변경 이력 추적 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- db/migrations/XXX_add_node_flow_versions.sql
|
||||||
|
CREATE TABLE node_flow_versions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
flow_data JSONB NOT NULL,
|
||||||
|
change_description TEXT,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE(flow_id, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_flow_versions_flow_id ON node_flow_versions(flow_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 플로우 수정 시 버전 저장
|
||||||
|
async function updateNodeFlow(flowId, flowData, changeDescription, userId) {
|
||||||
|
// 현재 버전 조회
|
||||||
|
const currentVersion = await queryOne(
|
||||||
|
'SELECT COALESCE(MAX(version), 0) as max_version FROM node_flow_versions WHERE flow_id = $1',
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 버전 저장
|
||||||
|
await query(
|
||||||
|
'INSERT INTO node_flow_versions (flow_id, version, flow_data, change_description, created_by) VALUES ($1, $2, $3, $4, $5)',
|
||||||
|
[flowId, currentVersion.max_version + 1, flowData, changeDescription, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 업데이트 로직...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] 버전 테이블 마이그레이션 생성
|
||||||
|
- [ ] 플로우 수정 시 버전 자동 저장
|
||||||
|
- [ ] 버전 목록 조회 API (`GET /api/dataflow/node-flows/:flowId/versions`)
|
||||||
|
- [ ] 특정 버전으로 롤백 API (`POST /api/dataflow/node-flows/:flowId/rollback/:version`)
|
||||||
|
- [ ] 프론트엔드 버전 히스토리 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. [우선순위 중간] 복합 조건 지원
|
||||||
|
|
||||||
|
**현재 상태**: 조건 노드에서 단일 조건만 지원
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 복잡한 비즈니스 로직 표현 불가
|
||||||
|
- 여러 조건을 AND/OR로 조합 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 복합 조건 타입 정의
|
||||||
|
interface ConditionGroup {
|
||||||
|
type: 'AND' | 'OR';
|
||||||
|
conditions: (Condition | ConditionGroup)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Condition {
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건 평가 함수 수정
|
||||||
|
function evaluateConditionGroup(group: ConditionGroup, data: any): boolean {
|
||||||
|
const results = group.conditions.map(condition => {
|
||||||
|
if ('type' in condition) {
|
||||||
|
// 중첩된 그룹
|
||||||
|
return evaluateConditionGroup(condition, data);
|
||||||
|
} else {
|
||||||
|
// 단일 조건
|
||||||
|
return evaluateCondition(data[condition.field], condition.operator, condition.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return group.type === 'AND'
|
||||||
|
? results.every(r => r)
|
||||||
|
: results.some(r => r);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] 복합 조건 타입 정의
|
||||||
|
- [ ] `evaluateConditionGroup` 함수 구현
|
||||||
|
- [ ] 조건 노드 속성 패널 UI 수정 (AND/OR 그룹 빌더)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. [우선순위 중간] 비동기 실행
|
||||||
|
|
||||||
|
**현재 상태**: 동기 실행만 가능 (HTTP 요청 타임아웃 제한)
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 대용량 데이터 처리 시 타임아웃
|
||||||
|
- 장시간 실행 플로우 처리 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 실행 큐 테이블
|
||||||
|
CREATE TABLE node_flow_execution_queue (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id),
|
||||||
|
execution_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'queued', -- queued, running, completed, failed
|
||||||
|
context_data JSONB,
|
||||||
|
callback_url TEXT,
|
||||||
|
result JSONB,
|
||||||
|
error_message TEXT,
|
||||||
|
queued_by VARCHAR(50),
|
||||||
|
company_code VARCHAR(20),
|
||||||
|
queued_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 비동기 실행 API
|
||||||
|
router.post("/:flowId/execute-async", async (req, res) => {
|
||||||
|
const { callbackUrl, contextData } = req.body;
|
||||||
|
|
||||||
|
// 큐에 추가
|
||||||
|
const execution = await queryOne(
|
||||||
|
`INSERT INTO node_flow_execution_queue (flow_id, context_data, callback_url, queued_by, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING execution_id`,
|
||||||
|
[flowId, contextData, callbackUrl, req.user?.userId, req.user?.companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 백그라운드 워커가 처리
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
executionId: execution.execution_id,
|
||||||
|
status: 'queued'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 상태 조회 API
|
||||||
|
router.get("/executions/:executionId", async (req, res) => {
|
||||||
|
const execution = await queryOne(
|
||||||
|
'SELECT * FROM node_flow_execution_queue WHERE execution_id = $1',
|
||||||
|
[req.params.executionId]
|
||||||
|
);
|
||||||
|
return res.json({ success: true, data: execution });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] 실행 큐 테이블 마이그레이션
|
||||||
|
- [ ] 비동기 실행 API 추가
|
||||||
|
- [ ] 백그라운드 워커 프로세스 구현 (별도 프로세스 또는 Bull 큐)
|
||||||
|
- [ ] 웹훅 콜백 기능 구현
|
||||||
|
- [ ] 프론트엔드 비동기 실행 상태 폴링 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. [우선순위 낮음] 플로우 스케줄링
|
||||||
|
|
||||||
|
**현재 상태**: 수동 실행만 가능
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 정기적인 배치 작업 자동화 불가
|
||||||
|
- 특정 시간 예약 실행 불가
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 스케줄 테이블
|
||||||
|
CREATE TABLE node_flow_schedules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
flow_id INTEGER NOT NULL REFERENCES node_flows(flow_id) ON DELETE CASCADE,
|
||||||
|
schedule_name VARCHAR(100),
|
||||||
|
cron_expression VARCHAR(50) NOT NULL, -- '0 9 * * 1-5' (평일 9시)
|
||||||
|
context_data JSONB,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
last_run_at TIMESTAMP,
|
||||||
|
next_run_at TIMESTAMP,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
company_code VARCHAR(20),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] 스케줄 테이블 마이그레이션
|
||||||
|
- [ ] 스케줄 CRUD API
|
||||||
|
- [ ] node-cron 또는 Bull 스케줄러 통합
|
||||||
|
- [ ] 스케줄 관리 UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. [우선순위 낮음] 플러그인 아키텍처
|
||||||
|
|
||||||
|
**현재 상태**: 새 노드 타입 추가 시 `nodeFlowExecutionService.ts` 직접 수정 필요
|
||||||
|
|
||||||
|
**문제점**:
|
||||||
|
- 코드 복잡도 증가
|
||||||
|
- 확장성 제한
|
||||||
|
|
||||||
|
**개선 방안**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// interfaces/NodeHandler.ts
|
||||||
|
export interface NodeHandler {
|
||||||
|
type: string;
|
||||||
|
execute(node: FlowNode, inputData: any, context: ExecutionContext, client?: any): Promise<any>;
|
||||||
|
validate?(node: FlowNode): { valid: boolean; errors: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlers/InsertActionHandler.ts
|
||||||
|
export class InsertActionHandler implements NodeHandler {
|
||||||
|
type = 'insertAction';
|
||||||
|
|
||||||
|
async execute(node, inputData, context, client) {
|
||||||
|
// 기존 executeInsertAction 로직
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeHandlerRegistry.ts
|
||||||
|
class NodeHandlerRegistry {
|
||||||
|
private handlers = new Map<string, NodeHandler>();
|
||||||
|
|
||||||
|
register(handler: NodeHandler) {
|
||||||
|
this.handlers.set(handler.type, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(type: string): NodeHandler | undefined {
|
||||||
|
return this.handlers.get(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
const registry = new NodeHandlerRegistry();
|
||||||
|
registry.register(new InsertActionHandler());
|
||||||
|
registry.register(new UpdateActionHandler());
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// executeNodeByType에서
|
||||||
|
const handler = registry.get(node.type);
|
||||||
|
if (handler) {
|
||||||
|
return handler.execute(node, inputData, context, client);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필요 작업**:
|
||||||
|
- [ ] `NodeHandler` 인터페이스 정의
|
||||||
|
- [ ] 기존 노드 타입별 핸들러 클래스 분리
|
||||||
|
- [ ] `NodeHandlerRegistry` 구현
|
||||||
|
- [ ] 커스텀 노드 핸들러 등록 메커니즘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. [우선순위 낮음] 프론트엔드 연동 강화
|
||||||
|
|
||||||
|
**현재 상태**: 기본 에디터 구현됨
|
||||||
|
|
||||||
|
**개선 필요 항목**:
|
||||||
|
- [ ] 실행 결과 시각화 (노드별 성공/실패 표시)
|
||||||
|
- [ ] 실시간 실행 진행률 표시
|
||||||
|
- [ ] 드라이런 모드 UI
|
||||||
|
- [ ] 실행 이력 조회 UI
|
||||||
|
- [ ] 버전 히스토리 UI
|
||||||
|
- [ ] 노드 검증 결과 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프론트엔드 컴포넌트 CRUD 로직 이전 계획
|
||||||
|
|
||||||
|
현재 프론트엔드 컴포넌트에서 직접 CRUD를 수행하는 코드들을 노드 플로우로 이전해야 합니다.
|
||||||
|
|
||||||
|
### 이전 대상 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 위치 | 현재 로직 | 이전 우선순위 |
|
||||||
|
|----------|----------|----------|--------------|
|
||||||
|
| SplitPanelLayoutComponent | `frontend/lib/registry/components/split-panel-layout/` | createRecord, updateRecord, deleteRecord | 높음 |
|
||||||
|
| RepeatScreenModalComponent | `frontend/lib/registry/components/repeat-screen-modal/` | 다중 테이블 INSERT/UPDATE/DELETE | 높음 |
|
||||||
|
| UniversalFormModalComponent | `frontend/lib/registry/components/universal-form-modal/` | 다중 행 저장 | 높음 |
|
||||||
|
| SelectedItemsDetailInputComponent | `frontend/lib/registry/components/selected-items-detail-input/` | upsertGroupedRecords | 높음 |
|
||||||
|
| ButtonPrimaryComponent | `frontend/lib/registry/components/button-primary/` | 상태 변경 POST | 중간 |
|
||||||
|
| SimpleRepeaterTableComponent | `frontend/lib/registry/components/simple-repeater-table/` | 데이터 저장 POST | 중간 |
|
||||||
|
|
||||||
|
### 이전 방식
|
||||||
|
|
||||||
|
1. **플로우 생성**: 각 컴포넌트의 저장 로직을 노드 플로우로 구현
|
||||||
|
2. **프론트엔드 수정**: 직접 API 호출 대신 `executeNodeFlow(flowId, contextData)` 호출
|
||||||
|
3. **화면 설정에 플로우 연결**: 버튼 액션에 실행할 플로우 ID 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 현재 (프론트엔드에서 직접 호출)
|
||||||
|
const result = await dataApi.createRecord(tableName, data);
|
||||||
|
|
||||||
|
// 개선 후 (플로우 실행)
|
||||||
|
const result = await executeNodeFlow(flowId, {
|
||||||
|
formData: data,
|
||||||
|
tableName: tableName,
|
||||||
|
action: 'create'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- 노드 플로우 실행 엔진: `backend-node/src/services/nodeFlowExecutionService.ts`
|
||||||
|
- 플로우 타입 정의: `backend-node/src/types/flow.ts`
|
||||||
|
- 프론트엔드 플로우 에디터: `frontend/components/dataflow/node-editor/FlowEditor.tsx`
|
||||||
|
- 프론트엔드 플로우 API: `frontend/lib/api/nodeFlows.ts`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,699 @@
|
||||||
|
# 레벨 기반 연쇄 드롭다운 시스템 설계
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
다양한 계층 구조를 지원하는 범용 연쇄 드롭다운 시스템 구축
|
||||||
|
|
||||||
|
### 1.2 지원하는 계층 유형
|
||||||
|
|
||||||
|
| 유형 | 설명 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| **MULTI_TABLE** | 각 레벨이 다른 테이블 | 국가 → 시/도 → 구/군 → 동 |
|
||||||
|
| **SELF_REFERENCE** | 같은 테이블 내 자기참조 | 대분류 → 중분류 → 소분류 |
|
||||||
|
| **BOM** | BOM 구조 (수량 등 속성 포함) | 제품 → 어셈블리 → 부품 |
|
||||||
|
| **TREE** | 무한 깊이 트리 | 조직도, 메뉴 구조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 데이터베이스 설계
|
||||||
|
|
||||||
|
### 2.1 테이블 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_group │ ← 계층 그룹 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (PK) │
|
||||||
|
│ group_name │
|
||||||
|
│ hierarchy_type │ ← MULTI_TABLE/SELF_REFERENCE/BOM/TREE
|
||||||
|
│ max_levels │
|
||||||
|
│ is_fixed_levels │
|
||||||
|
│ self_ref_* (자기참조 설정) │
|
||||||
|
│ bom_* (BOM 설정) │
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 1:N (MULTI_TABLE 유형만)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_hierarchy_level │ ← 레벨별 테이블/컬럼 정의
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ group_code (FK) │
|
||||||
|
│ level_order │ ← 1, 2, 3...
|
||||||
|
│ level_name │
|
||||||
|
│ table_name │
|
||||||
|
│ value_column │
|
||||||
|
│ label_column │
|
||||||
|
│ parent_key_column │ ← 부모 테이블 참조 컬럼
|
||||||
|
│ company_code │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 기존 시스템과의 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ cascading_relation │ ← 기존 2단계 관계 (유지)
|
||||||
|
│ (2단계 전용) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 호환성 뷰
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ v_cascading_as_hierarchy │ ← 기존 관계를 계층 형태로 변환
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 계층 유형별 상세 설계
|
||||||
|
|
||||||
|
### 3.1 MULTI_TABLE (다중 테이블 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 국가 → 시/도 → 구/군 → 동
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
country_info province_info city_info district_info
|
||||||
|
├─ country_code (PK) ├─ province_code (PK) ├─ city_code (PK) ├─ district_code (PK)
|
||||||
|
├─ country_name ├─ province_name ├─ city_name ├─ district_name
|
||||||
|
├─ country_code (FK) ├─ province_code (FK) ├─ city_code (FK)
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
) VALUES (
|
||||||
|
'REGION_HIERARCHY', '지역 계층', 'MULTI_TABLE', 4, 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 레벨 정의
|
||||||
|
INSERT INTO cascading_hierarchy_level VALUES
|
||||||
|
(1, 'REGION_HIERARCHY', 'EMAX', 1, '국가', 'country_info', 'country_code', 'country_name', NULL),
|
||||||
|
(2, 'REGION_HIERARCHY', 'EMAX', 2, '시/도', 'province_info', 'province_code', 'province_name', 'country_code'),
|
||||||
|
(3, 'REGION_HIERARCHY', 'EMAX', 3, '구/군', 'city_info', 'city_code', 'city_name', 'province_code'),
|
||||||
|
(4, 'REGION_HIERARCHY', 'EMAX', 4, '동', 'district_info', 'district_code', 'district_name', 'city_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (국가): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/1
|
||||||
|
→ [{ value: 'KR', label: '대한민국' }, { value: 'US', label: '미국' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (시/도): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/2?parentValue=KR
|
||||||
|
→ [{ value: 'SEOUL', label: '서울특별시' }, { value: 'BUSAN', label: '부산광역시' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (구/군): GET /api/cascading-hierarchy/options/REGION_HIERARCHY/3?parentValue=SEOUL
|
||||||
|
→ [{ value: 'GANGNAM', label: '강남구' }, { value: 'SEOCHO', label: '서초구' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 SELF_REFERENCE (자기참조 계층)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 카테고리 (대분류 → 중분류 → 소분류)
|
||||||
|
|
||||||
|
**테이블 구조** (code_info 활용):
|
||||||
|
```
|
||||||
|
code_info
|
||||||
|
├─ code_category = 'PRODUCT_CATEGORY'
|
||||||
|
├─ code_value (PK) = 'ELEC', 'ELEC_TV', 'ELEC_TV_LED'
|
||||||
|
├─ code_name = '전자제품', 'TV', 'LED TV'
|
||||||
|
├─ parent_code = NULL, 'ELEC', 'ELEC_TV' ← 자기참조
|
||||||
|
├─ level = 1, 2, 3
|
||||||
|
├─ sort_order
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_level_column,
|
||||||
|
self_ref_filter_column, self_ref_filter_value,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_CATEGORY', '제품 카테고리', 'SELF_REFERENCE', 3,
|
||||||
|
'code_info', 'code_value', 'parent_code',
|
||||||
|
'code_value', 'code_name', 'level',
|
||||||
|
'code_category', 'PRODUCT_CATEGORY',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 레벨 1 (대분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/1
|
||||||
|
→ WHERE parent_code IS NULL AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC', label: '전자제품' }, { value: 'FURN', label: '가구' }]
|
||||||
|
|
||||||
|
2. 레벨 2 (중분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/2?parentValue=ELEC
|
||||||
|
→ WHERE parent_code = 'ELEC' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV', label: 'TV' }, { value: 'ELEC_REF', label: '냉장고' }]
|
||||||
|
|
||||||
|
3. 레벨 3 (소분류): GET /api/cascading-hierarchy/options/PRODUCT_CATEGORY/3?parentValue=ELEC_TV
|
||||||
|
→ WHERE parent_code = 'ELEC_TV' AND code_category = 'PRODUCT_CATEGORY'
|
||||||
|
→ [{ value: 'ELEC_TV_LED', label: 'LED TV' }, { value: 'ELEC_TV_OLED', label: 'OLED TV' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 BOM (Bill of Materials)
|
||||||
|
|
||||||
|
**사용 사례**: 제품 BOM 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
klbom_tbl (BOM 관계) item_info (품목 마스터)
|
||||||
|
├─ id (자식 품목) ├─ item_code (PK)
|
||||||
|
├─ pid (부모 품목) ├─ item_name
|
||||||
|
├─ qty (수량) ├─ item_spec
|
||||||
|
├─ aylevel (레벨) ├─ unit
|
||||||
|
├─ bom_report_objid
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
bom_table, bom_parent_column, bom_child_column,
|
||||||
|
bom_item_table, bom_item_id_column, bom_item_label_column,
|
||||||
|
bom_qty_column, bom_level_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'PRODUCT_BOM', '제품 BOM', 'BOM', NULL, 'N',
|
||||||
|
'klbom_tbl', 'pid', 'id',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'qty', 'aylevel',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름**:
|
||||||
|
```
|
||||||
|
1. 루트 품목 (레벨 1): GET /api/cascading-hierarchy/bom/PRODUCT_BOM/roots
|
||||||
|
→ WHERE pid IS NULL OR pid = ''
|
||||||
|
→ [{ value: 'PROD001', label: '완제품 A', level: 1 }]
|
||||||
|
|
||||||
|
2. 하위 품목: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=PROD001
|
||||||
|
→ WHERE pid = 'PROD001'
|
||||||
|
→ [
|
||||||
|
{ value: 'ASSY001', label: '어셈블리 A', qty: 1, level: 2 },
|
||||||
|
{ value: 'ASSY002', label: '어셈블리 B', qty: 2, level: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
3. 더 하위: GET /api/cascading-hierarchy/bom/PRODUCT_BOM/children?parentValue=ASSY001
|
||||||
|
→ WHERE pid = 'ASSY001'
|
||||||
|
→ [
|
||||||
|
{ value: 'PART001', label: '부품 A', qty: 4, level: 3 },
|
||||||
|
{ value: 'PART002', label: '부품 B', qty: 2, level: 3 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**BOM 전용 응답 형식**:
|
||||||
|
```typescript
|
||||||
|
interface BomOption {
|
||||||
|
value: string; // 품목 코드
|
||||||
|
label: string; // 품목명
|
||||||
|
qty: number; // 수량
|
||||||
|
level: number; // BOM 레벨
|
||||||
|
hasChildren: boolean; // 하위 품목 존재 여부
|
||||||
|
spec?: string; // 규격 (선택)
|
||||||
|
unit?: string; // 단위 (선택)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 TREE (무한 깊이 트리)
|
||||||
|
|
||||||
|
**사용 사례**: 조직도, 메뉴 구조
|
||||||
|
|
||||||
|
**테이블 구조**:
|
||||||
|
```
|
||||||
|
dept_info
|
||||||
|
├─ dept_code (PK)
|
||||||
|
├─ dept_name
|
||||||
|
├─ parent_dept_code ← 자기참조 (무한 깊이)
|
||||||
|
├─ sort_order
|
||||||
|
├─ is_active
|
||||||
|
```
|
||||||
|
|
||||||
|
**설정 예시**:
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, is_fixed_levels,
|
||||||
|
self_ref_table, self_ref_id_column, self_ref_parent_column,
|
||||||
|
self_ref_value_column, self_ref_label_column, self_ref_order_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ORG_CHART', '조직도', 'TREE', NULL, 'N',
|
||||||
|
'dept_info', 'dept_code', 'parent_dept_code',
|
||||||
|
'dept_code', 'dept_name', 'sort_order',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 호출 흐름** (BOM과 유사):
|
||||||
|
```
|
||||||
|
1. 루트 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/roots
|
||||||
|
→ WHERE parent_dept_code IS NULL
|
||||||
|
→ [{ value: 'HQ', label: '본사', hasChildren: true }]
|
||||||
|
|
||||||
|
2. 하위 노드: GET /api/cascading-hierarchy/tree/ORG_CHART/children?parentValue=HQ
|
||||||
|
→ WHERE parent_dept_code = 'HQ'
|
||||||
|
→ [
|
||||||
|
{ value: 'DIV1', label: '사업부1', hasChildren: true },
|
||||||
|
{ value: 'DIV2', label: '사업부2', hasChildren: true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 설계
|
||||||
|
|
||||||
|
### 4.1 계층 그룹 관리 API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups # 그룹 목록
|
||||||
|
POST /api/cascading-hierarchy/groups # 그룹 생성
|
||||||
|
GET /api/cascading-hierarchy/groups/:code # 그룹 상세
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code # 그룹 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code # 그룹 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 레벨 관리 API (MULTI_TABLE용)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cascading-hierarchy/groups/:code/levels # 레벨 목록
|
||||||
|
POST /api/cascading-hierarchy/groups/:code/levels # 레벨 추가
|
||||||
|
PUT /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 수정
|
||||||
|
DELETE /api/cascading-hierarchy/groups/:code/levels/:order # 레벨 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 옵션 조회 API
|
||||||
|
|
||||||
|
```
|
||||||
|
# MULTI_TABLE / SELF_REFERENCE
|
||||||
|
GET /api/cascading-hierarchy/options/:groupCode/:level
|
||||||
|
?parentValue=xxx # 부모 값 (레벨 2 이상)
|
||||||
|
&companyCode=xxx # 회사 코드 (선택)
|
||||||
|
|
||||||
|
# BOM / TREE
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/roots # 루트 노드
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/children # 자식 노드
|
||||||
|
?parentValue=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/path # 경로 조회
|
||||||
|
?value=xxx
|
||||||
|
GET /api/cascading-hierarchy/tree/:groupCode/search # 검색
|
||||||
|
?keyword=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 컴포넌트 설계
|
||||||
|
|
||||||
|
### 5.1 CascadingHierarchyDropdown
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyDropdownProps {
|
||||||
|
groupCode: string; // 계층 그룹 코드
|
||||||
|
level: number; // 현재 레벨 (1, 2, 3...)
|
||||||
|
parentValue?: string; // 부모 값 (레벨 2 이상)
|
||||||
|
value?: string; // 선택된 값
|
||||||
|
onChange: (value: string, option: HierarchyOption) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시 (지역 계층)
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={1} onChange={setCountry} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={2} parentValue={country} onChange={setProvince} />
|
||||||
|
<CascadingHierarchyDropdown groupCode="REGION_HIERARCHY" level={3} parentValue={province} onChange={setCity} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 CascadingHierarchyGroup (자동 연결)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CascadingHierarchyGroupProps {
|
||||||
|
groupCode: string;
|
||||||
|
values: Record<number, string>; // { 1: 'KR', 2: 'SEOUL', 3: 'GANGNAM' }
|
||||||
|
onChange: (level: number, value: string) => void;
|
||||||
|
layout?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<CascadingHierarchyGroup
|
||||||
|
groupCode="REGION_HIERARCHY"
|
||||||
|
values={regionValues}
|
||||||
|
onChange={(level, value) => {
|
||||||
|
setRegionValues(prev => ({ ...prev, [level]: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 BomTreeSelect (BOM 전용)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BomTreeSelectProps {
|
||||||
|
groupCode: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string, path: BomOption[]) => void;
|
||||||
|
showQty?: boolean; // 수량 표시
|
||||||
|
showLevel?: boolean; // 레벨 표시
|
||||||
|
maxDepth?: number; // 최대 깊이 제한
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예시
|
||||||
|
<BomTreeSelect
|
||||||
|
groupCode="PRODUCT_BOM"
|
||||||
|
value={selectedPart}
|
||||||
|
onChange={(value, path) => {
|
||||||
|
setSelectedPart(value);
|
||||||
|
console.log('선택 경로:', path); // [완제품 → 어셈블리 → 부품]
|
||||||
|
}}
|
||||||
|
showQty
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 화면관리 시스템 통합
|
||||||
|
|
||||||
|
### 6.1 컴포넌트 설정 확장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SelectBasicConfig {
|
||||||
|
// 기존 설정
|
||||||
|
cascadingEnabled?: boolean;
|
||||||
|
cascadingRelationCode?: string; // 기존 2단계 관계
|
||||||
|
cascadingRole?: 'parent' | 'child';
|
||||||
|
cascadingParentField?: string;
|
||||||
|
|
||||||
|
// 🆕 레벨 기반 계층 설정
|
||||||
|
hierarchyEnabled?: boolean;
|
||||||
|
hierarchyGroupCode?: string; // 계층 그룹 코드
|
||||||
|
hierarchyLevel?: number; // 이 컴포넌트의 레벨
|
||||||
|
hierarchyParentField?: string; // 부모 레벨 필드명
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 설정 UI 확장
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 설정 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ ○ 2단계 관계 (기존) │
|
||||||
|
│ └─ 관계 선택: [창고-위치 ▼] │
|
||||||
|
│ └─ 역할: [부모] [자식] │
|
||||||
|
│ │
|
||||||
|
│ ● 다단계 계층 (신규) │
|
||||||
|
│ └─ 계층 그룹: [지역 계층 ▼] │
|
||||||
|
│ └─ 레벨: [2 - 시/도 ▼] │
|
||||||
|
│ └─ 부모 필드: [country_code] (자동감지) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 구현 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 기반 구축
|
||||||
|
1. ✅ 기존 2단계 연쇄 드롭다운 완성
|
||||||
|
2. 📋 데이터베이스 마이그레이션 (066_create_cascading_hierarchy.sql)
|
||||||
|
3. 📋 백엔드 API 구현 (계층 그룹 CRUD)
|
||||||
|
|
||||||
|
### Phase 2: MULTI_TABLE 지원
|
||||||
|
1. 📋 레벨 관리 API
|
||||||
|
2. 📋 옵션 조회 API
|
||||||
|
3. 📋 프론트엔드 컴포넌트
|
||||||
|
|
||||||
|
### Phase 3: SELF_REFERENCE 지원
|
||||||
|
1. 📋 자기참조 쿼리 로직
|
||||||
|
2. 📋 code_info 기반 카테고리 계층
|
||||||
|
|
||||||
|
### Phase 4: BOM/TREE 지원
|
||||||
|
1. 📋 BOM 전용 API
|
||||||
|
2. 📋 트리 컴포넌트
|
||||||
|
3. 📋 무한 깊이 지원
|
||||||
|
|
||||||
|
### Phase 5: 화면관리 통합
|
||||||
|
1. 📋 설정 UI 확장
|
||||||
|
2. 📋 자동 연결 기능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 성능 고려사항
|
||||||
|
|
||||||
|
### 8.1 쿼리 최적화
|
||||||
|
- 인덱스: `(group_code, company_code, level_order)`
|
||||||
|
- 캐싱: 자주 조회되는 옵션 목록 Redis 캐싱
|
||||||
|
- Lazy Loading: 하위 레벨은 필요 시에만 로드
|
||||||
|
|
||||||
|
### 8.2 BOM 재귀 쿼리
|
||||||
|
```sql
|
||||||
|
-- PostgreSQL WITH RECURSIVE 활용
|
||||||
|
WITH RECURSIVE bom_tree AS (
|
||||||
|
-- 루트 노드
|
||||||
|
SELECT id, pid, qty, 1 AS level
|
||||||
|
FROM klbom_tbl
|
||||||
|
WHERE pid IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 하위 노드
|
||||||
|
SELECT b.id, b.pid, b.qty, t.level + 1
|
||||||
|
FROM klbom_tbl b
|
||||||
|
JOIN bom_tree t ON b.pid = t.id
|
||||||
|
WHERE t.level < 10 -- 최대 깊이 제한
|
||||||
|
)
|
||||||
|
SELECT * FROM bom_tree;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 트리 최적화 전략
|
||||||
|
- Materialized Path: `/HQ/DIV1/DEPT1/TEAM1`
|
||||||
|
- Nested Set: left/right 값으로 범위 쿼리
|
||||||
|
- Closure Table: 별도 관계 테이블
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 추가 연쇄 패턴
|
||||||
|
|
||||||
|
### 9.1 조건부 연쇄 (Conditional Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 특정 조건에 따라 다른 옵션 목록 표시
|
||||||
|
|
||||||
|
```
|
||||||
|
입고유형: [구매입고] → 창고: [원자재창고, 부품창고] 만 표시
|
||||||
|
입고유형: [생산입고] → 창고: [완제품창고, 반제품창고] 만 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_condition`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_condition (
|
||||||
|
relation_code, condition_name,
|
||||||
|
condition_field, condition_operator, condition_value,
|
||||||
|
filter_column, filter_values, company_code
|
||||||
|
) VALUES
|
||||||
|
('WAREHOUSE_LOCATION', '구매입고 창고',
|
||||||
|
'inbound_type', 'EQ', 'PURCHASE',
|
||||||
|
'warehouse_type', 'RAW_MATERIAL,PARTS', 'EMAX');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.2 다중 부모 연쇄 (Multi-Parent Cascading)
|
||||||
|
|
||||||
|
**사용 사례**: 여러 부모 필드의 조합으로 자식 필터링
|
||||||
|
|
||||||
|
```
|
||||||
|
회사: [A사] + 사업부: [영업부문] → 부서: [영업1팀, 영업2팀]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_multi_parent`, `cascading_multi_parent_source`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 관계 정의
|
||||||
|
INSERT INTO cascading_multi_parent (
|
||||||
|
relation_code, relation_name,
|
||||||
|
child_table, child_value_column, child_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'COMPANY_DIVISION_DEPT', '회사-사업부-부서',
|
||||||
|
'dept_info', 'dept_code', 'dept_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 부모 소스 정의
|
||||||
|
INSERT INTO cascading_multi_parent_source (
|
||||||
|
relation_code, company_code, parent_order, parent_name,
|
||||||
|
parent_table, parent_value_column, child_filter_column
|
||||||
|
) VALUES
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 1, '회사', 'company_info', 'company_code', 'company_code'),
|
||||||
|
('COMPANY_DIVISION_DEPT', 'EMAX', 2, '사업부', 'division_info', 'division_code', 'division_code');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.3 자동 입력 그룹 (Auto-Fill Group)
|
||||||
|
|
||||||
|
**사용 사례**: 마스터 선택 시 여러 필드 자동 입력
|
||||||
|
|
||||||
|
```
|
||||||
|
고객사 선택 → 담당자, 연락처, 주소, 결제조건 자동 입력
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_auto_fill_group`, `cascading_auto_fill_mapping`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 그룹 정의
|
||||||
|
INSERT INTO cascading_auto_fill_group (
|
||||||
|
group_code, group_name,
|
||||||
|
master_table, master_value_column, master_label_column, company_code
|
||||||
|
) VALUES (
|
||||||
|
'CUSTOMER_AUTO_FILL', '고객사 정보 자동입력',
|
||||||
|
'customer_info', 'customer_code', 'customer_name', 'EMAX'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 필드 매핑
|
||||||
|
INSERT INTO cascading_auto_fill_mapping (
|
||||||
|
group_code, company_code, source_column, target_field, target_label
|
||||||
|
) VALUES
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_person', 'contact_name', '담당자'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'contact_phone', 'contact_phone', '연락처'),
|
||||||
|
('CUSTOMER_AUTO_FILL', 'EMAX', 'address', 'delivery_address', '배송주소');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.4 상호 배제 (Mutual Exclusion)
|
||||||
|
|
||||||
|
**사용 사례**: 같은 값 선택 불가
|
||||||
|
|
||||||
|
```
|
||||||
|
출발 창고: [창고A] → 도착 창고: [창고B, 창고C] (창고A 제외)
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_mutual_exclusion`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_mutual_exclusion (
|
||||||
|
exclusion_code, exclusion_name, field_names,
|
||||||
|
source_table, value_column, label_column,
|
||||||
|
error_message, company_code
|
||||||
|
) VALUES (
|
||||||
|
'WAREHOUSE_TRANSFER', '창고간 이동',
|
||||||
|
'from_warehouse_code,to_warehouse_code',
|
||||||
|
'warehouse_info', 'warehouse_code', 'warehouse_name',
|
||||||
|
'출발 창고와 도착 창고는 같을 수 없습니다',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9.5 역방향 조회 (Reverse Lookup)
|
||||||
|
|
||||||
|
**사용 사례**: 자식에서 부모 방향으로 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
품목: [부품A] 선택 → 사용처 BOM: [제품X, 제품Y, 제품Z]
|
||||||
|
```
|
||||||
|
|
||||||
|
**테이블**: `cascading_reverse_lookup`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO cascading_reverse_lookup (
|
||||||
|
lookup_code, lookup_name,
|
||||||
|
source_table, source_value_column, source_label_column,
|
||||||
|
target_table, target_value_column, target_label_column, target_link_column,
|
||||||
|
company_code
|
||||||
|
) VALUES (
|
||||||
|
'ITEM_USED_IN_BOM', '품목 사용처 BOM',
|
||||||
|
'item_info', 'item_code', 'item_name',
|
||||||
|
'klbom_tbl', 'pid', 'ayupgname', 'id',
|
||||||
|
'EMAX'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 전체 테이블 구조 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 연쇄 드롭다운 시스템 구조 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [기존 - 2단계] │
|
||||||
|
│ cascading_relation ─────────────────────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다단계 계층] │
|
||||||
|
│ cascading_hierarchy_group ──┬── cascading_hierarchy_level │
|
||||||
|
│ │ (MULTI_TABLE용) │
|
||||||
|
│ │ │
|
||||||
|
│ [신규 - 조건부] │
|
||||||
|
│ cascading_condition ────────┴── 조건에 따른 필터링 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 다중 부모] │
|
||||||
|
│ cascading_multi_parent ─────┬── cascading_multi_parent_source │
|
||||||
|
│ │ (여러 부모 조합) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 자동 입력] │
|
||||||
|
│ cascading_auto_fill_group ──┬── cascading_auto_fill_mapping │
|
||||||
|
│ │ (마스터→다중 필드) │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 상호 배제] │
|
||||||
|
│ cascading_mutual_exclusion ─┴── 같은 값 선택 불가 │
|
||||||
|
│ │
|
||||||
|
│ [신규 - 역방향] │
|
||||||
|
│ cascading_reverse_lookup ───┴── 자식→부모 조회 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 마이그레이션 가이드
|
||||||
|
|
||||||
|
### 11.1 기존 데이터 마이그레이션
|
||||||
|
```sql
|
||||||
|
-- 기존 cascading_relation → cascading_hierarchy_group 변환
|
||||||
|
INSERT INTO cascading_hierarchy_group (
|
||||||
|
group_code, group_name, hierarchy_type, max_levels, company_code
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'LEGACY_' || relation_code,
|
||||||
|
relation_name,
|
||||||
|
'MULTI_TABLE',
|
||||||
|
2,
|
||||||
|
company_code
|
||||||
|
FROM cascading_relation
|
||||||
|
WHERE is_active = 'Y';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 호환성 유지
|
||||||
|
- 기존 `cascading_relation` 테이블 유지
|
||||||
|
- 기존 API 엔드포인트 유지
|
||||||
|
- 점진적 마이그레이션 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 구현 우선순위 (업데이트)
|
||||||
|
|
||||||
|
| Phase | 기능 | 복잡도 | 우선순위 |
|
||||||
|
|-------|------|--------|----------|
|
||||||
|
| 1 | 기존 2단계 연쇄 (cascading_relation) | 완료 | 완료 |
|
||||||
|
| 2 | 다단계 계층 - MULTI_TABLE | 중 | 높음 |
|
||||||
|
| 3 | 다단계 계층 - SELF_REFERENCE | 중 | 높음 |
|
||||||
|
| 4 | 자동 입력 그룹 (Auto-Fill) | 낮음 | 높음 |
|
||||||
|
| 5 | 조건부 연쇄 | 중 | 중간 |
|
||||||
|
| 6 | 상호 배제 | 낮음 | 중간 |
|
||||||
|
| 7 | 다중 부모 연쇄 | 높음 | 낮음 |
|
||||||
|
| 8 | BOM/TREE 구조 | 높음 | 낮음 |
|
||||||
|
| 9 | 역방향 조회 | 중 | 낮음 |
|
||||||
|
|
||||||
|
|
@ -0,0 +1,357 @@
|
||||||
|
# 메일 발송 기능 사용 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
노드 기반 제어관리 시스템을 통해 메일을 발송하는 방법을 설명합니다.
|
||||||
|
화면에서 데이터를 선택하고, 수신자를 지정하여 템플릿 기반의 메일을 발송할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 사전 준비
|
||||||
|
|
||||||
|
### 1.1 메일 계정 등록
|
||||||
|
|
||||||
|
메일 발송을 위해 먼저 SMTP 계정을 등록해야 합니다.
|
||||||
|
|
||||||
|
1. **관리자** > **메일관리** > **계정관리** 이동
|
||||||
|
2. **새 계정 추가** 클릭
|
||||||
|
3. SMTP 정보 입력:
|
||||||
|
- 계정명: 식별용 이름 (예: "회사 공식 메일")
|
||||||
|
- 이메일: 발신자 이메일 주소
|
||||||
|
- SMTP 호스트: 메일 서버 주소 (예: smtp.gmail.com)
|
||||||
|
- SMTP 포트: 포트 번호 (예: 587)
|
||||||
|
- 보안: TLS/SSL 선택
|
||||||
|
- 사용자명/비밀번호: SMTP 인증 정보
|
||||||
|
4. **저장** 후 **테스트 발송**으로 동작 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 제어관리 설정
|
||||||
|
|
||||||
|
### 2.1 메일 발송 플로우 생성
|
||||||
|
|
||||||
|
**관리자** > **제어관리** > **플로우 관리**에서 새 플로우를 생성합니다.
|
||||||
|
|
||||||
|
#### 기본 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스] → [메일 발송]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 노드 구성
|
||||||
|
|
||||||
|
1. **테이블 소스 노드** 추가
|
||||||
|
|
||||||
|
- 데이터 소스: **컨텍스트 데이터** (화면에서 선택한 데이터 사용)
|
||||||
|
- 또는 **테이블 전체 데이터** (주의: 전체 데이터 건수만큼 메일 발송)
|
||||||
|
|
||||||
|
2. **메일 발송 노드** 추가
|
||||||
|
|
||||||
|
- 노드 팔레트 > 외부 실행 > **메일 발송** 드래그
|
||||||
|
|
||||||
|
3. 두 노드 연결 (테이블 소스 → 메일 발송)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 메일 발송 노드 설정
|
||||||
|
|
||||||
|
메일 발송 노드를 클릭하면 우측에 속성 패널이 표시됩니다.
|
||||||
|
|
||||||
|
#### 계정 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| -------------- | ----------------------------------- |
|
||||||
|
| 발송 계정 선택 | 사전에 등록한 메일 계정 선택 (필수) |
|
||||||
|
|
||||||
|
#### 메일 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| -------------------- | ------------------------------------------------ |
|
||||||
|
| 수신자 컴포넌트 사용 | 체크 시 화면의 수신자 선택 컴포넌트 값 자동 사용 |
|
||||||
|
| 수신자 필드명 | 수신자 변수명 (기본: mailTo) |
|
||||||
|
| 참조 필드명 | 참조 변수명 (기본: mailCc) |
|
||||||
|
| 수신자 (To) | 직접 입력 또는 변수 사용 (예: `{{email}}`) |
|
||||||
|
| 참조 (CC) | 참조 수신자 |
|
||||||
|
| 숨은 참조 (BCC) | 숨은 참조 수신자 |
|
||||||
|
| 우선순위 | 높음 / 보통 / 낮음 |
|
||||||
|
|
||||||
|
#### 본문 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| --------- | -------------------------------- |
|
||||||
|
| 제목 | 메일 제목 (변수 사용 가능) |
|
||||||
|
| 본문 형식 | 텍스트 (변수 태그 에디터) / HTML |
|
||||||
|
| 본문 내용 | 메일 본문 (변수 사용 가능) |
|
||||||
|
|
||||||
|
#### 옵션 탭
|
||||||
|
|
||||||
|
| 설정 | 설명 |
|
||||||
|
| ----------- | ------------------- |
|
||||||
|
| 타임아웃 | 발송 제한 시간 (ms) |
|
||||||
|
| 재시도 횟수 | 실패 시 재시도 횟수 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 변수 사용 방법
|
||||||
|
|
||||||
|
메일 제목과 본문에서 `{{변수명}}` 형식으로 데이터 필드를 참조할 수 있습니다.
|
||||||
|
|
||||||
|
#### 텍스트 모드 (변수 태그 에디터)
|
||||||
|
|
||||||
|
1. 본문 형식을 **텍스트 (변수 태그 에디터)** 선택
|
||||||
|
2. 에디터에서 `@` 또는 `/` 키 입력
|
||||||
|
3. 변수 목록에서 원하는 변수 선택
|
||||||
|
4. 선택된 변수는 파란색 태그로 표시
|
||||||
|
|
||||||
|
#### HTML 모드 (직접 입력)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h1>주문 확인</h1>
|
||||||
|
<p>안녕하세요 {{customerName}}님,</p>
|
||||||
|
<p>주문번호 {{orderNo}}의 주문이 완료되었습니다.</p>
|
||||||
|
<p>금액: {{totalAmount}}원</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 사용 가능한 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| ---------------- | ------------------------ |
|
||||||
|
| `{{timestamp}}` | 메일 발송 시점 |
|
||||||
|
| `{{sourceData}}` | 전체 소스 데이터 (JSON) |
|
||||||
|
| `{{필드명}}` | 테이블 소스의 각 컬럼 값 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 화면 구성
|
||||||
|
|
||||||
|
### 3.1 기본 구조
|
||||||
|
|
||||||
|
메일 발송 화면은 보통 다음과 같이 구성합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
[부모 화면: 데이터 목록]
|
||||||
|
↓ (모달 열기 버튼)
|
||||||
|
[모달: 수신자 입력 + 발송 버튼]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 수신자 선택 컴포넌트 배치
|
||||||
|
|
||||||
|
1. **화면관리**에서 모달 화면 편집
|
||||||
|
2. 컴포넌트 팔레트 > **메일 수신자 선택** 드래그
|
||||||
|
3. 컴포넌트 설정:
|
||||||
|
- 수신자 필드명: `mailTo` (메일 발송 노드와 일치)
|
||||||
|
- 참조 필드명: `mailCc` (메일 발송 노드와 일치)
|
||||||
|
|
||||||
|
#### 수신자 선택 기능
|
||||||
|
|
||||||
|
- **내부 사용자**: 회사 직원 목록에서 검색/선택
|
||||||
|
- **외부 이메일**: 직접 이메일 주소 입력
|
||||||
|
- 여러 명 선택 가능 (쉼표로 구분)
|
||||||
|
|
||||||
|
### 3.3 발송 버튼 설정
|
||||||
|
|
||||||
|
1. **버튼** 컴포넌트 추가
|
||||||
|
2. 버튼 설정:
|
||||||
|
- 액션 타입: **제어 실행**
|
||||||
|
- 플로우 선택: 생성한 메일 발송 플로우
|
||||||
|
- 데이터 소스: **자동** 또는 **폼 + 테이블 선택**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 전체 흐름 예시
|
||||||
|
|
||||||
|
### 4.1 시나리오: 선택한 주문 건에 대해 고객에게 메일 발송
|
||||||
|
|
||||||
|
#### Step 1: 제어관리 플로우 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스: 컨텍스트 데이터]
|
||||||
|
↓
|
||||||
|
[메일 발송]
|
||||||
|
- 계정: 회사 공식 메일
|
||||||
|
- 수신자 컴포넌트 사용: 체크
|
||||||
|
- 제목: [주문확인] {{orderNo}} 주문이 완료되었습니다
|
||||||
|
- 본문:
|
||||||
|
안녕하세요 {{customerName}}님,
|
||||||
|
|
||||||
|
주문번호 {{orderNo}}의 주문이 정상 처리되었습니다.
|
||||||
|
|
||||||
|
- 상품명: {{productName}}
|
||||||
|
- 수량: {{quantity}}
|
||||||
|
- 금액: {{totalAmount}}원
|
||||||
|
|
||||||
|
감사합니다.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 부모 화면 (주문 목록)
|
||||||
|
|
||||||
|
- 주문 데이터 테이블
|
||||||
|
- "메일 발송" 버튼
|
||||||
|
- 액션: 모달 열기
|
||||||
|
- 모달 화면: 메일 발송 모달
|
||||||
|
- 선택된 데이터 전달: 체크
|
||||||
|
|
||||||
|
#### Step 3: 모달 화면 (메일 발송)
|
||||||
|
|
||||||
|
- 메일 수신자 선택 컴포넌트
|
||||||
|
- 수신자 (To) 입력
|
||||||
|
- 참조 (CC) 입력
|
||||||
|
- "발송" 버튼
|
||||||
|
- 액션: 제어 실행
|
||||||
|
- 플로우: 메일 발송 플로우
|
||||||
|
|
||||||
|
#### Step 4: 실행 흐름
|
||||||
|
|
||||||
|
1. 사용자가 주문 목록에서 주문 선택
|
||||||
|
2. "메일 발송" 버튼 클릭 → 모달 열림
|
||||||
|
3. 수신자/참조 입력
|
||||||
|
4. "발송" 버튼 클릭
|
||||||
|
5. 제어 실행:
|
||||||
|
- 부모 화면 데이터 (orderNo, customerName 등) + 모달 폼 데이터 (mailTo, mailCc) 병합
|
||||||
|
- 변수 치환 후 메일 발송
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터 소스별 동작
|
||||||
|
|
||||||
|
### 5.1 컨텍스트 데이터 (권장)
|
||||||
|
|
||||||
|
- 화면에서 **선택한 데이터**만 사용
|
||||||
|
- 선택한 건수만큼 메일 발송
|
||||||
|
|
||||||
|
| 선택 건수 | 메일 발송 수 |
|
||||||
|
| --------- | ------------ |
|
||||||
|
| 1건 | 1통 |
|
||||||
|
| 5건 | 5통 |
|
||||||
|
| 10건 | 10통 |
|
||||||
|
|
||||||
|
### 5.2 테이블 전체 데이터 (주의)
|
||||||
|
|
||||||
|
- 테이블의 **모든 데이터** 사용
|
||||||
|
- 전체 건수만큼 메일 발송
|
||||||
|
|
||||||
|
| 테이블 데이터 | 메일 발송 수 |
|
||||||
|
| ------------- | ------------ |
|
||||||
|
| 100건 | 100통 |
|
||||||
|
| 1000건 | 1000통 |
|
||||||
|
|
||||||
|
**주의사항:**
|
||||||
|
|
||||||
|
- 대량 발송 시 SMTP 서버 rate limit 주의
|
||||||
|
- 테스트 시 반드시 데이터 건수 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 문제 해결
|
||||||
|
|
||||||
|
### 6.1 메일이 발송되지 않음
|
||||||
|
|
||||||
|
1. **계정 설정 확인**: 메일관리 > 계정관리에서 테스트 발송 확인
|
||||||
|
2. **수신자 확인**: 수신자 이메일 주소가 올바른지 확인
|
||||||
|
3. **플로우 연결 확인**: 테이블 소스 → 메일 발송 노드가 연결되어 있는지 확인
|
||||||
|
|
||||||
|
### 6.2 변수가 치환되지 않음
|
||||||
|
|
||||||
|
1. **변수명 확인**: `{{변수명}}`에서 변수명이 테이블 컬럼명과 일치하는지 확인
|
||||||
|
2. **데이터 소스 확인**: 테이블 소스 노드가 올바른 데이터를 가져오는지 확인
|
||||||
|
3. **데이터 전달 확인**: 부모 화면 → 모달로 데이터가 전달되는지 확인
|
||||||
|
|
||||||
|
### 6.3 수신자 컴포넌트 값이 전달되지 않음
|
||||||
|
|
||||||
|
1. **필드명 일치 확인**:
|
||||||
|
- 수신자 컴포넌트의 필드명과 메일 발송 노드의 필드명이 일치해야 함
|
||||||
|
- 기본값: `mailTo`, `mailCc`
|
||||||
|
2. **수신자 컴포넌트 사용 체크**: 메일 발송 노드에서 "수신자 컴포넌트 사용" 활성화
|
||||||
|
|
||||||
|
### 6.4 부모 화면 데이터가 메일에 포함되지 않음
|
||||||
|
|
||||||
|
1. **모달 열기 설정 확인**: "선택된 데이터 전달" 옵션 활성화
|
||||||
|
2. **데이터 소스 설정 확인**: 발송 버튼의 데이터 소스가 "자동" 또는 "폼 + 테이블 선택"인지 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 고급 기능
|
||||||
|
|
||||||
|
### 7.1 조건부 메일 발송
|
||||||
|
|
||||||
|
조건 분기 노드를 사용하여 특정 조건에서만 메일을 발송할 수 있습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
[테이블 소스]
|
||||||
|
↓
|
||||||
|
[조건 분기: status === 'approved']
|
||||||
|
↓ (true)
|
||||||
|
[메일 발송: 승인 알림]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 다중 수신자 처리
|
||||||
|
|
||||||
|
수신자 필드에 쉼표로 구분하여 여러 명에게 동시 발송:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{managerEmail}}, {{teamLeadEmail}}, external@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 HTML 템플릿 활용
|
||||||
|
|
||||||
|
본문 형식을 HTML로 설정하면 풍부한 형식의 메일을 보낼 수 있습니다:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>주문 확인</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>안녕하세요 <strong>{{customerName}}</strong>님,</p>
|
||||||
|
<p>주문번호 <strong>{{orderNo}}</strong>의 주문이 완료되었습니다.</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>상품명</td>
|
||||||
|
<td>{{productName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>금액</td>
|
||||||
|
<td>{{totalAmount}}원</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer">본 메일은 자동 발송되었습니다.</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 체크리스트
|
||||||
|
|
||||||
|
메일 발송 기능 구현 시 확인 사항:
|
||||||
|
|
||||||
|
- [ ] 메일 계정이 등록되어 있는가?
|
||||||
|
- [ ] 메일 계정 테스트 발송이 성공하는가?
|
||||||
|
- [ ] 제어관리에 메일 발송 플로우가 생성되어 있는가?
|
||||||
|
- [ ] 테이블 소스 노드의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
- [ ] 메일 발송 노드에서 계정이 선택되어 있는가?
|
||||||
|
- [ ] 수신자 컴포넌트 사용 시 필드명이 일치하는가?
|
||||||
|
- [ ] 변수명이 테이블 컬럼명과 일치하는가?
|
||||||
|
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
|
||||||
|
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 자동입력 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default function AutoFillRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/admin/cascading-management?tab=autofill");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||||
|
|
||||||
|
// 탭별 컴포넌트
|
||||||
|
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||||
|
import AutoFillTab from "./tabs/AutoFillTab";
|
||||||
|
import HierarchyTab from "./tabs/HierarchyTab";
|
||||||
|
import ConditionTab from "./tabs/ConditionTab";
|
||||||
|
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||||
|
|
||||||
|
export default function CascadingManagementPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState("relations");
|
||||||
|
|
||||||
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = searchParams.get("tab");
|
||||||
|
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// 탭 변경 시 URL 업데이트
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("tab", value);
|
||||||
|
router.replace(url.pathname + url.search);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 탭 네비게이션 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-5">
|
||||||
|
<TabsTrigger value="relations" className="gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||||
|
<span className="sm:hidden">연쇄</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="hierarchy" className="gap-2">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">다단계 계층</span>
|
||||||
|
<span className="sm:hidden">계층</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="condition" className="gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">조건부 필터</span>
|
||||||
|
<span className="sm:hidden">조건</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="autofill" className="gap-2">
|
||||||
|
<FormInput className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">자동 입력</span>
|
||||||
|
<span className="sm:hidden">자동</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="exclusion" className="gap-2">
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">상호 배제</span>
|
||||||
|
<span className="sm:hidden">배제</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<TabsContent value="relations">
|
||||||
|
<CascadingRelationsTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="hierarchy">
|
||||||
|
<HierarchyTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="condition">
|
||||||
|
<ConditionTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="autofill">
|
||||||
|
<AutoFillTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="exclusion">
|
||||||
|
<MutualExclusionTab />
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,686 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
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 { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ArrowRight,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableColumn {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoFillTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||||
|
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터
|
||||||
|
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setGroups(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("그룹 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 컬럼 로드
|
||||||
|
const loadColumns = useCallback(async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setMasterColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setMasterColumns(
|
||||||
|
response.data.columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||||
|
dataType: col.dataType || col.data_type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
setMasterColumns([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadGroups, loadTableList]);
|
||||||
|
|
||||||
|
// 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.masterTable) {
|
||||||
|
loadColumns(formData.masterTable);
|
||||||
|
}
|
||||||
|
}, [formData.masterTable, loadColumns]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
masterTable: "",
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
isActive: "Y",
|
||||||
|
});
|
||||||
|
setMappings([]);
|
||||||
|
setMasterColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
|
||||||
|
// 상세 정보 로드
|
||||||
|
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||||
|
if (detailResponse.success && detailResponse.data) {
|
||||||
|
const detail = detailResponse.data;
|
||||||
|
|
||||||
|
// 컬럼 먼저 로드
|
||||||
|
if (detail.masterTable) {
|
||||||
|
await loadColumns(detail.masterTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
groupCode: detail.groupCode,
|
||||||
|
groupName: detail.groupName,
|
||||||
|
description: detail.description || "",
|
||||||
|
masterTable: detail.masterTable,
|
||||||
|
masterValueColumn: detail.masterValueColumn,
|
||||||
|
masterLabelColumn: detail.masterLabelColumn || "",
|
||||||
|
isActive: detail.isActive || "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||||
|
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||||
|
sourceColumn: m.source_column || m.sourceColumn,
|
||||||
|
targetField: m.target_field || m.targetField,
|
||||||
|
targetLabel: m.target_label || m.targetLabel || "",
|
||||||
|
isEditable: m.is_editable || m.isEditable || "Y",
|
||||||
|
isRequired: m.is_required || m.isRequired || "N",
|
||||||
|
defaultValue: m.default_value || m.defaultValue || "",
|
||||||
|
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||||
|
}));
|
||||||
|
setMappings(convertedMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingAutoFillApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("자동 입력 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.groupName || !formData.masterTable || !formData.masterValueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveData = {
|
||||||
|
...formData,
|
||||||
|
mappings,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 추가
|
||||||
|
const handleAddMapping = () => {
|
||||||
|
setMappings([
|
||||||
|
...mappings,
|
||||||
|
{
|
||||||
|
sourceColumn: "",
|
||||||
|
targetField: "",
|
||||||
|
targetLabel: "",
|
||||||
|
isEditable: "Y",
|
||||||
|
isRequired: "N",
|
||||||
|
defaultValue: "",
|
||||||
|
sortOrder: mappings.length + 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 삭제
|
||||||
|
const handleRemoveMapping = (index: number) => {
|
||||||
|
setMappings(mappings.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑 수정
|
||||||
|
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||||
|
const updated = [...mappings];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setMappings(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 및 액션 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름, 테이블명으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>자동 입력 그룹</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
마스터 선택 시 여러 필드를 자동으로 입력합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>그룹 코드</TableHead>
|
||||||
|
<TableHead>그룹명</TableHead>
|
||||||
|
<TableHead>마스터 테이블</TableHead>
|
||||||
|
<TableHead>매핑 수</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<TableRow key={group.groupCode}>
|
||||||
|
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 고객사 정보 자동입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={formData.isActive === "Y"}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||||
|
/>
|
||||||
|
<Label>활성화</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 마스터 테이블 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>마스터 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.masterTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||||
|
formData.masterTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
masterTable: table.tableName,
|
||||||
|
masterValueColumn: "",
|
||||||
|
masterLabelColumn: "",
|
||||||
|
});
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
|
{table.displayName && table.displayName !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterValueColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.masterLabelColumn}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||||
|
disabled={!formData.masterTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||||
|
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mappings.map((mapping, index) => (
|
||||||
|
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
|
|
||||||
|
{/* 소스 컬럼 */}
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceColumn}
|
||||||
|
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="소스 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{masterColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
|
||||||
|
{/* 타겟 필드 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetField}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||||
|
placeholder="타겟 필드명 (예: contact_name)"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타겟 라벨 */}
|
||||||
|
<div className="w-28">
|
||||||
|
<Input
|
||||||
|
value={mapping.targetLabel || ""}
|
||||||
|
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`editable-${index}`}
|
||||||
|
checked={mapping.isEditable === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||||
|
수정
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`required-${index}`}
|
||||||
|
checked={mapping.isRequired === "Y"}
|
||||||
|
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||||
|
필수
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleRemoveMapping(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>자동 입력 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 자동 입력 그룹을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,898 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
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 { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Link2,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CascadingRelationsTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 테이블/컬럼 목록
|
||||||
|
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||||||
|
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||||||
|
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||||||
|
|
||||||
|
// 폼 상태
|
||||||
|
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 고급 설정 토글
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
||||||
|
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 목록 조회
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 조회
|
||||||
|
const loadTableList = useCallback(async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTableList(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName || t.name,
|
||||||
|
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컬럼 목록 조회 (수정됨)
|
||||||
|
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
setParentColumns([]);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// getColumnList 사용 (getTableColumns가 아님)
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 응답 구조: { data: { columns: [...] } }
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||||||
|
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
// 자동 추천
|
||||||
|
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||||||
|
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||||||
|
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(true);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columnList = response.data.columns || response.data;
|
||||||
|
|
||||||
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.name,
|
||||||
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === "parent") {
|
||||||
|
setParentColumns(columns);
|
||||||
|
} else {
|
||||||
|
setChildColumns(columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 조회 실패:", error);
|
||||||
|
} finally {
|
||||||
|
if (type === "parent") {
|
||||||
|
setLoadingParentColumns(false);
|
||||||
|
} else {
|
||||||
|
setLoadingChildColumns(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자동 컬럼 선택 (패턴 매칭)
|
||||||
|
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||||||
|
// 이미 값이 있으면 스킵
|
||||||
|
if (formData[field]) return;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||||||
|
if (found) {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRelations();
|
||||||
|
loadTableList();
|
||||||
|
}, [loadRelations, loadTableList]);
|
||||||
|
|
||||||
|
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.parentTable) {
|
||||||
|
loadColumns(formData.parentTable, "parent");
|
||||||
|
} else {
|
||||||
|
setParentColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.parentTable, editingRelation]);
|
||||||
|
|
||||||
|
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||||
|
useEffect(() => {
|
||||||
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||||
|
if (editingRelation) return;
|
||||||
|
|
||||||
|
if (formData.childTable) {
|
||||||
|
loadColumns(formData.childTable, "child");
|
||||||
|
} else {
|
||||||
|
setChildColumns([]);
|
||||||
|
}
|
||||||
|
}, [formData.childTable, editingRelation]);
|
||||||
|
|
||||||
|
// 관계 코드 자동 생성
|
||||||
|
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||||
|
return `${parent}_${child}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계명 자동 생성
|
||||||
|
const generateRelationName = (parentTable: string, childTable: string) => {
|
||||||
|
if (!parentTable || !childTable) return "";
|
||||||
|
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||||||
|
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||||||
|
const parentName = parentInfo?.tableLabel || parentTable;
|
||||||
|
const childName = childInfo?.tableLabel || childTable;
|
||||||
|
return `${parentName}-${childName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (신규)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingRelation(null);
|
||||||
|
setFormData({
|
||||||
|
relationCode: "",
|
||||||
|
relationName: "",
|
||||||
|
description: "",
|
||||||
|
parentTable: "",
|
||||||
|
parentValueColumn: "",
|
||||||
|
parentLabelColumn: "",
|
||||||
|
childTable: "",
|
||||||
|
childFilterColumn: "",
|
||||||
|
childValueColumn: "",
|
||||||
|
childLabelColumn: "",
|
||||||
|
childOrderColumn: "",
|
||||||
|
childOrderDirection: "ASC",
|
||||||
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
clearOnParentChange: true,
|
||||||
|
});
|
||||||
|
setParentColumns([]);
|
||||||
|
setChildColumns([]);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||||||
|
setEditingRelation(relation);
|
||||||
|
setShowAdvanced(false);
|
||||||
|
|
||||||
|
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||||||
|
const loadPromises: Promise<void>[] = [];
|
||||||
|
if (relation.parent_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||||||
|
}
|
||||||
|
if (relation.child_table) {
|
||||||
|
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 로드 완료 대기
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
|
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||||||
|
setFormData({
|
||||||
|
relationCode: relation.relation_code,
|
||||||
|
relationName: relation.relation_name,
|
||||||
|
description: relation.description || "",
|
||||||
|
parentTable: relation.parent_table,
|
||||||
|
parentValueColumn: relation.parent_value_column,
|
||||||
|
parentLabelColumn: relation.parent_label_column || "",
|
||||||
|
childTable: relation.child_table,
|
||||||
|
childFilterColumn: relation.child_filter_column,
|
||||||
|
childValueColumn: relation.child_value_column,
|
||||||
|
childLabelColumn: relation.child_label_column,
|
||||||
|
childOrderColumn: relation.child_order_column || "",
|
||||||
|
childOrderDirection: relation.child_order_direction || "ASC",
|
||||||
|
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||||||
|
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||||||
|
loadingMessage: relation.loading_message || "로딩 중...",
|
||||||
|
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부모 테이블 선택 시 자동 설정
|
||||||
|
const handleParentTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.parentTable;
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parentTable: value,
|
||||||
|
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||||||
|
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "parent");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자식 테이블 선택 시 자동 설정
|
||||||
|
const handleChildTableChange = async (value: string) => {
|
||||||
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||||
|
const shouldClearColumns = value !== formData.childTable;
|
||||||
|
|
||||||
|
const newFormData = {
|
||||||
|
...formData,
|
||||||
|
childTable: value,
|
||||||
|
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||||||
|
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||||||
|
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||||||
|
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||||||
|
if (!editingRelation) {
|
||||||
|
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||||||
|
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(newFormData);
|
||||||
|
|
||||||
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||||
|
if (editingRelation && value) {
|
||||||
|
await loadColumnsForEdit(value, "child");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필수 필드 검증
|
||||||
|
if (!formData.parentTable || !formData.parentValueColumn) {
|
||||||
|
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!formData.childTable ||
|
||||||
|
!formData.childFilterColumn ||
|
||||||
|
!formData.childValueColumn ||
|
||||||
|
!formData.childLabelColumn
|
||||||
|
) {
|
||||||
|
toast.error("자식 테이블 설정을 완료해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관계 코드/이름 자동 생성 (비어있으면)
|
||||||
|
const finalData = { ...formData };
|
||||||
|
if (!finalData.relationCode) {
|
||||||
|
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
if (!finalData.relationName) {
|
||||||
|
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingRelation) {
|
||||||
|
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingRelationApi.create(finalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
const handleDelete = async (relation: CascadingRelation) => {
|
||||||
|
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("연쇄 관계가 삭제되었습니다.");
|
||||||
|
loadRelations();
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 목록
|
||||||
|
const filteredRelations = relations.filter(
|
||||||
|
(r) =>
|
||||||
|
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 셀렉트 렌더링 헬퍼
|
||||||
|
const renderColumnSelect = (
|
||||||
|
value: string,
|
||||||
|
onChange: (v: string) => void,
|
||||||
|
columns: ColumnInfo[],
|
||||||
|
loading: boolean,
|
||||||
|
placeholder: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
) => (
|
||||||
|
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span className="text-xs">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||||||
|
) : (
|
||||||
|
columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{col.columnLabel}</span>
|
||||||
|
{col.columnLabel !== col.columnName && (
|
||||||
|
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-5 w-5" />
|
||||||
|
2단계 연쇄 관계
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>부모-자식 관계로 연결된 드롭다운을 정의합니다. (예: 창고 → 위치)</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>관계명</TableHead>
|
||||||
|
<TableHead>연결</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="w-[100px]">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredRelations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||||
|
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredRelations.map((relation) => (
|
||||||
|
<TableRow key={relation.relation_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{relation.relation_name}</div>
|
||||||
|
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||||||
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||||
|
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||||||
|
{relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||||||
|
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 - 간소화된 UI */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||||||
|
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1: 부모 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={parentTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{loadingTables
|
||||||
|
? "로딩 중..."
|
||||||
|
: formData.parentTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
||||||
|
formData.parentTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleParentTableChange(table.tableName);
|
||||||
|
setParentTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.parentValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||||||
|
parentColumns,
|
||||||
|
loadingParentColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.parentTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: 자식 테이블 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={childTableComboOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
disabled={!formData.parentTable}
|
||||||
|
>
|
||||||
|
{formData.childTable
|
||||||
|
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
||||||
|
formData.childTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleChildTableChange(table.tableName);
|
||||||
|
setChildTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||||
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childFilterColumn,
|
||||||
|
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childValueColumn,
|
||||||
|
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||||||
|
{renderColumnSelect(
|
||||||
|
formData.childLabelColumn,
|
||||||
|
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||||||
|
childColumns,
|
||||||
|
loadingChildColumns,
|
||||||
|
"컬럼 선택",
|
||||||
|
!formData.childTable,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관계 정보 (자동 생성) */}
|
||||||
|
{formData.parentTable && formData.childTable && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계 코드</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={!!editingRelation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">관계명</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||||||
|
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||||||
|
placeholder="자동 생성"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 고급 설정 토글 */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<span>고급 설정</span>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="이 관계에 대한 설명..."
|
||||||
|
rows={2}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">상위 미선택 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.emptyParentMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">옵션 없음 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.noOptionsMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.clearOnParentChange}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : editingRelation ? (
|
||||||
|
"수정"
|
||||||
|
) : (
|
||||||
|
"생성"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
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 { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
cascadingConditionApi,
|
||||||
|
CascadingCondition,
|
||||||
|
CONDITION_OPERATORS,
|
||||||
|
} from "@/lib/api/cascadingCondition";
|
||||||
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
|
export default function ConditionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||||
|
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||||
|
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadConditions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setConditions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("조건 목록 로드 실패:", error);
|
||||||
|
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
const loadRelations = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelations(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConditions();
|
||||||
|
loadRelations();
|
||||||
|
}, [loadConditions, loadRelations]);
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredConditions = conditions.filter(
|
||||||
|
(c) =>
|
||||||
|
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingCondition(null);
|
||||||
|
setFormData({
|
||||||
|
relationType: "RELATION",
|
||||||
|
relationCode: "",
|
||||||
|
conditionName: "",
|
||||||
|
conditionField: "",
|
||||||
|
conditionOperator: "EQ",
|
||||||
|
conditionValue: "",
|
||||||
|
filterColumn: "",
|
||||||
|
filterValues: "",
|
||||||
|
priority: 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||||
|
setEditingCondition(condition);
|
||||||
|
setFormData({
|
||||||
|
relationType: condition.relationType || "RELATION",
|
||||||
|
relationCode: condition.relationCode,
|
||||||
|
conditionName: condition.conditionName,
|
||||||
|
conditionField: condition.conditionField,
|
||||||
|
conditionOperator: condition.conditionOperator,
|
||||||
|
conditionValue: condition.conditionValue,
|
||||||
|
filterColumn: condition.filterColumn,
|
||||||
|
filterValues: condition.filterValues,
|
||||||
|
priority: condition.priority || 0,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (conditionId: number) => {
|
||||||
|
setDeletingConditionId(conditionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingConditionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingConditionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||||
|
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingCondition) {
|
||||||
|
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||||
|
} else {
|
||||||
|
response = await cascadingConditionApi.create(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadConditions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연산자 라벨 찾기
|
||||||
|
const getOperatorLabel = (operator: string) => {
|
||||||
|
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="조건명, 관계 코드, 조건 필드로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadConditions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5" />
|
||||||
|
조건부 필터 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredConditions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 조건부 필터 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 상태별 품목 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"상태" 필드가 "활성"일 때만 "품목" 드롭다운에 활성 품목만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 유형별 옵션 필터</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"유형" 필드가 "입고"일 때 "창고" 드롭다운에 입고 가능 창고만 표시
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>연쇄 관계</TableHead>
|
||||||
|
<TableHead>조건명</TableHead>
|
||||||
|
<TableHead>조건</TableHead>
|
||||||
|
<TableHead>필터</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredConditions.map((condition) => (
|
||||||
|
<TableRow key={condition.conditionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||||
|
<span className="mx-1 text-blue-600">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||||
|
<span className="font-medium">{condition.conditionValue}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||||
|
<span className="mx-1">=</span>
|
||||||
|
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 연쇄 관계 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>연쇄 관계 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.relationCode}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="연쇄 관계 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relations.map((rel) => (
|
||||||
|
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||||
|
{rel.relation_name} ({rel.relation_code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>조건명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||||
|
placeholder="예: 활성 품목만 표시"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조건 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionField}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">연산자 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.conditionOperator}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONDITION_OPERATORS.map((op) => (
|
||||||
|
<SelectItem key={op.value} value={op.value}>
|
||||||
|
{op.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">조건 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.conditionValue}
|
||||||
|
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||||
|
placeholder="예: active"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 설정 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 컬럼 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterColumn}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||||
|
placeholder="예: status"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">필터 값 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.filterValues}
|
||||||
|
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||||
|
placeholder="예: active,pending"
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우선순위 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>우선순위</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||||
|
placeholder="높을수록 먼저 적용"
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>조건부 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 조건부 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,847 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function HierarchyTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 확장된 그룹 (레벨 표시)
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||||||
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 레벨 모달
|
||||||
|
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||||||
|
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||||||
|
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||||||
|
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레벨 폼 데이터
|
||||||
|
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||||||
|
levelOrder: 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// snake_case를 camelCase로 변환하는 함수
|
||||||
|
const transformGroup = (g: any): HierarchyGroup => ({
|
||||||
|
groupId: g.group_id || g.groupId,
|
||||||
|
groupCode: g.group_code || g.groupCode,
|
||||||
|
groupName: g.group_name || g.groupName,
|
||||||
|
description: g.description,
|
||||||
|
hierarchyType: g.hierarchy_type || g.hierarchyType,
|
||||||
|
maxLevels: g.max_levels || g.maxLevels,
|
||||||
|
isFixedLevels: g.is_fixed_levels || g.isFixedLevels,
|
||||||
|
selfRefTable: g.self_ref_table || g.selfRefTable,
|
||||||
|
selfRefIdColumn: g.self_ref_id_column || g.selfRefIdColumn,
|
||||||
|
selfRefParentColumn: g.self_ref_parent_column || g.selfRefParentColumn,
|
||||||
|
selfRefValueColumn: g.self_ref_value_column || g.selfRefValueColumn,
|
||||||
|
selfRefLabelColumn: g.self_ref_label_column || g.selfRefLabelColumn,
|
||||||
|
selfRefLevelColumn: g.self_ref_level_column || g.selfRefLevelColumn,
|
||||||
|
selfRefOrderColumn: g.self_ref_order_column || g.selfRefOrderColumn,
|
||||||
|
bomTable: g.bom_table || g.bomTable,
|
||||||
|
bomParentColumn: g.bom_parent_column || g.bomParentColumn,
|
||||||
|
bomChildColumn: g.bom_child_column || g.bomChildColumn,
|
||||||
|
bomItemTable: g.bom_item_table || g.bomItemTable,
|
||||||
|
bomItemIdColumn: g.bom_item_id_column || g.bomItemIdColumn,
|
||||||
|
bomItemLabelColumn: g.bom_item_label_column || g.bomItemLabelColumn,
|
||||||
|
bomQtyColumn: g.bom_qty_column || g.bomQtyColumn,
|
||||||
|
bomLevelColumn: g.bom_level_column || g.bomLevelColumn,
|
||||||
|
emptyMessage: g.empty_message || g.emptyMessage,
|
||||||
|
noOptionsMessage: g.no_options_message || g.noOptionsMessage,
|
||||||
|
loadingMessage: g.loading_message || g.loadingMessage,
|
||||||
|
companyCode: g.company_code || g.companyCode,
|
||||||
|
isActive: g.is_active || g.isActive,
|
||||||
|
createdBy: g.created_by || g.createdBy,
|
||||||
|
createdDate: g.created_date || g.createdDate,
|
||||||
|
updatedBy: g.updated_by || g.updatedBy,
|
||||||
|
updatedDate: g.updated_date || g.updatedDate,
|
||||||
|
levelCount: g.level_count || g.levelCount || 0,
|
||||||
|
levels: g.levels,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadGroups = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getGroups();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// snake_case를 camelCase로 변환
|
||||||
|
const transformedData = response.data.map(transformGroup);
|
||||||
|
setGroups(transformedData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("계층 그룹 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGroups();
|
||||||
|
loadTables();
|
||||||
|
}, [loadGroups, loadTables]);
|
||||||
|
|
||||||
|
// 그룹 레벨 로드
|
||||||
|
const loadGroupLevels = async (groupCode: string) => {
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.getDetail(groupCode);
|
||||||
|
if (response.success && response.data?.levels) {
|
||||||
|
setGroupLevels((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[groupCode]: response.data!.levels || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레벨 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 확장 토글
|
||||||
|
const toggleGroupExpand = async (groupCode: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups);
|
||||||
|
if (newExpanded.has(groupCode)) {
|
||||||
|
newExpanded.delete(groupCode);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(groupCode);
|
||||||
|
if (!groupLevels[groupCode]) {
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 로드 (레벨 폼용)
|
||||||
|
const loadLevelColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setLevelColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setLevelColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredGroups = groups.filter(
|
||||||
|
(g) =>
|
||||||
|
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingGroup(null);
|
||||||
|
setFormData({
|
||||||
|
groupName: "",
|
||||||
|
description: "",
|
||||||
|
hierarchyType: "MULTI_TABLE",
|
||||||
|
maxLevels: undefined,
|
||||||
|
isFixedLevels: "Y",
|
||||||
|
emptyMessage: "선택해주세요",
|
||||||
|
noOptionsMessage: "옵션이 없습니다",
|
||||||
|
loadingMessage: "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = (group: HierarchyGroup) => {
|
||||||
|
setEditingGroup(group);
|
||||||
|
setFormData({
|
||||||
|
groupCode: group.groupCode,
|
||||||
|
groupName: group.groupName,
|
||||||
|
description: group.description || "",
|
||||||
|
hierarchyType: group.hierarchyType,
|
||||||
|
maxLevels: group.maxLevels,
|
||||||
|
isFixedLevels: group.isFixedLevels || "Y",
|
||||||
|
emptyMessage: group.emptyMessage || "선택해주세요",
|
||||||
|
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||||||
|
loadingMessage: group.loadingMessage || "로딩 중...",
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (groupCode: string) => {
|
||||||
|
setDeletingGroupCode(groupCode);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingGroupCode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("계층 그룹이 삭제되었습니다.");
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingGroupCode(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.groupName || !formData.hierarchyType) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingGroup) {
|
||||||
|
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (생성)
|
||||||
|
const handleOpenCreateLevel = (groupCode: string) => {
|
||||||
|
setCurrentGroupCode(groupCode);
|
||||||
|
setEditingLevel(null);
|
||||||
|
const existingLevels = groupLevels[groupCode] || [];
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: existingLevels.length + 1,
|
||||||
|
levelName: "",
|
||||||
|
levelCode: "",
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
orderColumn: "",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
placeholder: "",
|
||||||
|
isRequired: "Y",
|
||||||
|
isSearchable: "N",
|
||||||
|
});
|
||||||
|
setLevelColumns([]);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 모달 열기 (수정)
|
||||||
|
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||||||
|
setCurrentGroupCode(level.groupCode);
|
||||||
|
setEditingLevel(level);
|
||||||
|
setLevelFormData({
|
||||||
|
levelOrder: level.levelOrder,
|
||||||
|
levelName: level.levelName,
|
||||||
|
levelCode: level.levelCode || "",
|
||||||
|
tableName: level.tableName,
|
||||||
|
valueColumn: level.valueColumn,
|
||||||
|
labelColumn: level.labelColumn,
|
||||||
|
parentKeyColumn: level.parentKeyColumn || "",
|
||||||
|
orderColumn: level.orderColumn || "",
|
||||||
|
orderDirection: level.orderDirection || "ASC",
|
||||||
|
placeholder: level.placeholder || "",
|
||||||
|
isRequired: level.isRequired || "Y",
|
||||||
|
isSearchable: level.isSearchable || "N",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(level.tableName);
|
||||||
|
setIsLevelModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 저장
|
||||||
|
const handleSaveLevel = async () => {
|
||||||
|
if (
|
||||||
|
!levelFormData.levelName ||
|
||||||
|
!levelFormData.tableName ||
|
||||||
|
!levelFormData.valueColumn ||
|
||||||
|
!levelFormData.labelColumn
|
||||||
|
) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingLevel) {
|
||||||
|
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||||||
|
} else {
|
||||||
|
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||||||
|
setIsLevelModalOpen(false);
|
||||||
|
await loadGroupLevels(currentGroupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레벨 삭제
|
||||||
|
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||||||
|
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await hierarchyApi.deleteLevel(levelId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("레벨이 삭제되었습니다.");
|
||||||
|
await loadGroupLevels(groupCode);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계층 타입 라벨
|
||||||
|
const getHierarchyTypeLabel = (type: string) => {
|
||||||
|
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="그룹 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadGroups}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5" />
|
||||||
|
다단계 계층
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredGroups.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||||||
|
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredGroups.map((group) => (
|
||||||
|
<div key={group.groupCode} className="rounded-lg border">
|
||||||
|
<div
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||||||
|
onClick={() => toggleGroupExpand(group.groupCode)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedGroups.has(group.groupCode) ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{group.groupName}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||||||
|
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||||||
|
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레벨 목록 */}
|
||||||
|
{expandedGroups.has(group.groupCode) && (
|
||||||
|
<div className="bg-muted/20 border-t p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">레벨 목록</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
레벨 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">순서</TableHead>
|
||||||
|
<TableHead>레벨명</TableHead>
|
||||||
|
<TableHead>테이블</TableHead>
|
||||||
|
<TableHead>값 컬럼</TableHead>
|
||||||
|
<TableHead>부모 키</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(groupLevels[group.groupCode] || []).map((level) => (
|
||||||
|
<TableRow key={level.levelId}>
|
||||||
|
<TableCell>{level.levelOrder}</TableCell>
|
||||||
|
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 그룹 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>그룹명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.groupName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||||||
|
placeholder="예: 지역 계층"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>계층 유형 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.hierarchyType}
|
||||||
|
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HIERARCHY_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>설명</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="계층 구조에 대한 설명"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>최대 레벨 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.maxLevels || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
placeholder="예: 4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>고정 레벨 여부</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.isFixedLevels}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">고정</SelectItem>
|
||||||
|
<SelectItem value="N">가변</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 레벨 생성/수정 모달 */}
|
||||||
|
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||||||
|
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨 순서 *</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={levelFormData.levelOrder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>레벨명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.levelName}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||||||
|
placeholder="예: 시/도"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{levelFormData.tableName
|
||||||
|
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||||||
|
levelFormData.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setLevelFormData({
|
||||||
|
...levelFormData,
|
||||||
|
tableName: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
parentKeyColumn: "",
|
||||||
|
});
|
||||||
|
await loadLevelColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.valueColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.labelColumn}
|
||||||
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||||||
|
<Select
|
||||||
|
value={levelFormData.parentKeyColumn || "__none__"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||||||
|
}
|
||||||
|
disabled={!levelFormData.tableName}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{levelColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>플레이스홀더</Label>
|
||||||
|
<Input
|
||||||
|
value={levelFormData.placeholder}
|
||||||
|
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||||||
|
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,582 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
|
export default function MutualExclusionTab() {
|
||||||
|
// 목록 상태
|
||||||
|
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||||
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
|
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// 테이블 Combobox 상태
|
||||||
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||||
|
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 필드 목록 (동적 추가)
|
||||||
|
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||||
|
|
||||||
|
// 목록 로드
|
||||||
|
const loadExclusions = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.getList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setExclusions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("상호 배제 목록 로드 실패:", error);
|
||||||
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
const loadTables = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExclusions();
|
||||||
|
loadTables();
|
||||||
|
}, [loadExclusions, loadTables]);
|
||||||
|
|
||||||
|
// 테이블 선택 시 컬럼 로드
|
||||||
|
const loadColumns = async (tableName: string) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터된 목록
|
||||||
|
const filteredExclusions = exclusions.filter(
|
||||||
|
(e) =>
|
||||||
|
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||||
|
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 열기 (생성)
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingExclusion(null);
|
||||||
|
setFormData({
|
||||||
|
exclusionName: "",
|
||||||
|
fieldNames: "",
|
||||||
|
sourceTable: "",
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
exclusionType: "SAME_VALUE",
|
||||||
|
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(["", ""]);
|
||||||
|
setColumns([]);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (수정)
|
||||||
|
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||||
|
setEditingExclusion(exclusion);
|
||||||
|
setFormData({
|
||||||
|
exclusionCode: exclusion.exclusionCode,
|
||||||
|
exclusionName: exclusion.exclusionName,
|
||||||
|
fieldNames: exclusion.fieldNames,
|
||||||
|
sourceTable: exclusion.sourceTable,
|
||||||
|
valueColumn: exclusion.valueColumn,
|
||||||
|
labelColumn: exclusion.labelColumn || "",
|
||||||
|
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||||
|
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||||
|
});
|
||||||
|
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||||
|
await loadColumns(exclusion.sourceTable);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleDeleteConfirm = (exclusionId: number) => {
|
||||||
|
setDeletingExclusionId(exclusionId);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 실행
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingExclusionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||||
|
if (response.success) {
|
||||||
|
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setDeletingExclusionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 추가
|
||||||
|
const addField = () => {
|
||||||
|
setFieldList([...fieldList, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 제거
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
if (fieldList.length <= 2) {
|
||||||
|
toast.error("최소 2개의 필드가 필요합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 값 변경
|
||||||
|
const updateField = (index: number, value: string) => {
|
||||||
|
const newFields = [...fieldList];
|
||||||
|
newFields[index] = value;
|
||||||
|
setFieldList(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 필드 목록 합치기
|
||||||
|
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||||
|
if (cleanedFields.length < 2) {
|
||||||
|
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||||
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSave = {
|
||||||
|
...formData,
|
||||||
|
fieldNames: cleanedFields.join(","),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editingExclusion) {
|
||||||
|
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||||
|
} else {
|
||||||
|
response = await mutualExclusionApi.create(dataToSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
loadExclusions();
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 선택 핸들러
|
||||||
|
const handleTableChange = async (tableName: string) => {
|
||||||
|
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||||
|
await loadColumns(tableName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder="배제 코드, 이름으로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadExclusions}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Ban className="h-5 w-5" />
|
||||||
|
상호 배제 규칙
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.length}개)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 규칙 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="ml-2">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredExclusions.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||||||
|
<div className="text-sm">
|
||||||
|
{searchText ? "검색 결과가 없습니다." : "등록된 상호 배제 규칙이 없습니다."}
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 창고 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"출발 창고"와 "도착 창고"가 같은 창고를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 부서 이동</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
"현재 부서"와 "이동 부서"가 같은 부서를 선택할 수 없도록 제한
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>배제 코드</TableHead>
|
||||||
|
<TableHead>배제명</TableHead>
|
||||||
|
<TableHead>대상 필드</TableHead>
|
||||||
|
<TableHead>소스 테이블</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredExclusions.map((exclusion) => (
|
||||||
|
<TableRow key={exclusion.exclusionId}>
|
||||||
|
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||||
|
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||||
|
<Badge key={idx} variant="outline" className="text-xs">
|
||||||
|
{field.trim()}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||||
|
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 생성/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||||
|
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 배제명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제명 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.exclusionName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||||
|
placeholder="예: 창고 이동 제한"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 필드 */}
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||||
|
<Button variant="outline" size="sm" onClick={addField}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{fieldList.map((field, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={field}
|
||||||
|
onChange={(e) => updateField(index, e.target.value)}
|
||||||
|
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
{fieldList.length > 2 && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 테이블 및 컬럼 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>소스 테이블 *</Label>
|
||||||
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboOpen}
|
||||||
|
className="h-10 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{formData.sourceTable
|
||||||
|
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||||
|
: "테이블 선택"}
|
||||||
|
<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-sm" />
|
||||||
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||||||
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables
|
||||||
|
.filter((t) => t.tableName)
|
||||||
|
.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
sourceTable: t.tableName,
|
||||||
|
valueColumn: "",
|
||||||
|
labelColumn: "",
|
||||||
|
});
|
||||||
|
await loadColumns(t.tableName);
|
||||||
|
setTableComboOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.sourceTable === t.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||||
|
{t.displayName && t.displayName !== t.tableName && (
|
||||||
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>값 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.valueColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="값 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라벨 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.labelColumn}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||||
|
disabled={!formData.sourceTable}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columns
|
||||||
|
.filter((c) => c.columnName)
|
||||||
|
.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>배제 유형</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.exclusionType}
|
||||||
|
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EXCLUSION_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>에러 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.errorMessage}
|
||||||
|
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||||
|
placeholder="동일한 값을 선택할 수 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>상호 배제 규칙 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
이 상호 배제 규칙을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 연쇄관계 페이지 → 통합 관리 페이지로 리다이렉트
|
||||||
|
*/
|
||||||
|
export default function CascadingRelationsRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace("/admin/cascading-management");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-muted-foreground text-sm">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import {
|
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
||||||
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
|
|
||||||
|
|
@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer";
|
||||||
*/
|
*/
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="bg-background min-h-screen">
|
||||||
<div className="w-full max-w-none px-4 pt-12 pb-16 space-y-16">
|
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
|
||||||
|
{/* 주요 관리 기능 */}
|
||||||
{/* 주요 관리 기능 */}
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
<div className="mb-8 text-center">
|
||||||
<div className="text-center mb-8">
|
<h2 className="text-foreground mb-2 text-2xl font-bold">주요 관리 기능</h2>
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">주요 관리 기능</h2>
|
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
||||||
<p className="text-muted-foreground">시스템의 핵심 관리 기능들을 제공합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Link href="/admin/userMng" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Users className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">사용자 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">사용자 계정 및 권한 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Link href="/admin/userMng" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Users className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">사용자 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">사용자 계정 및 권한 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* <div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
<Shield className="h-6 w-6 text-success" />
|
<Shield className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">권한 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">메뉴 및 기능 권한 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">권한 관리</h3>
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
<p className="text-sm text-muted-foreground">메뉴 및 기능 권한 설정</p>
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Settings className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">시스템 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">기본 설정 및 환경 구성</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card rounded-lg border p-6 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-warning/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<BarChart3 className="text-warning h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">통계 및 리포트</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 사용 현황 분석</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<Link href="/admin/screenMng" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Palette className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">화면관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">드래그앤드롭으로 화면 설계 및 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* 표준 관리 섹션 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<div className="mb-8 text-center">
|
||||||
<Settings className="h-6 w-6 text-primary" />
|
<h2 className="text-foreground mb-2 text-2xl font-bold">표준 관리</h2>
|
||||||
</div>
|
<p className="text-muted-foreground">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
||||||
<div>
|
</div>
|
||||||
<h3 className="font-semibold text-foreground">시스템 설정</h3>
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<p className="text-sm text-muted-foreground">기본 설정 및 환경 구성</p>
|
{/* <Link href="/admin/standards" className="block h-full">
|
||||||
</div>
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">웹타입 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">입력 컴포넌트 웹타입 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/templates" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Layout className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">템플릿 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">화면 디자이너 템플릿 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link> */}
|
||||||
|
|
||||||
|
<Link href="/admin/tableMng" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">테이블 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">데이터베이스 테이블 및 웹타입 매핑</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* <Link href="/admin/components" className="block h-full">
|
||||||
|
<div className="bg-card hover:bg-muted h-full rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Package className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">컴포넌트 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">화면 디자이너 컴포넌트 표준 관리</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm">
|
{/* 빠른 액세스 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-warning/10">
|
<div className="mb-8 text-center">
|
||||||
<BarChart3 className="h-6 w-6 text-warning" />
|
<h2 className="text-foreground mb-2 text-2xl font-bold">빠른 액세스</h2>
|
||||||
</div>
|
<p className="text-muted-foreground">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
||||||
<div>
|
</div>
|
||||||
<h3 className="font-semibold text-foreground">통계 및 리포트</h3>
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<p className="text-sm text-muted-foreground">시스템 사용 현황 분석</p>
|
<Link href="/admin/menu" className="block">
|
||||||
</div>
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Layout className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">메뉴 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 메뉴 및 네비게이션 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/external-connections" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-success/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Database className="text-success h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">외부 연결 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">외부 데이터베이스 연결 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/admin/commonCode" className="block">
|
||||||
|
<div className="bg-card hover:bg-muted rounded-lg border p-6 shadow-sm transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||||
|
<Settings className="text-primary h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-foreground font-semibold">공통 코드 관리</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">시스템 공통 코드 및 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href="/admin/screenMng" className="block">
|
{/* 전역 파일 관리 */}
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="mb-6 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<h2 className="text-foreground mb-2 text-2xl font-bold">전역 파일 관리</h2>
|
||||||
<Palette className="h-6 w-6 text-primary" />
|
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">화면관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">드래그앤드롭으로 화면 설계 및 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
<GlobalFileViewer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표준 관리 섹션 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">표준 관리</h2>
|
|
||||||
<p className="text-muted-foreground">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<Link href="/admin/standards" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Database className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">웹타입 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">입력 컴포넌트 웹타입 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/templates" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
|
||||||
<Layout className="h-6 w-6 text-success" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">템플릿 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">화면 디자이너 템플릿 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/tableMng" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Database className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">테이블 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">데이터베이스 테이블 및 웹타입 매핑</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/components" className="block h-full">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted h-full">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Package className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">컴포넌트 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">화면 디자이너 컴포넌트 표준 관리</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 빠른 액세스 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-10">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">빠른 액세스</h2>
|
|
||||||
<p className="text-muted-foreground">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
<Link href="/admin/menu" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Layout className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">메뉴 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템 메뉴 및 네비게이션 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/external-connections" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-success/10">
|
|
||||||
<Database className="h-6 w-6 text-success" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">외부 연결 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 연결 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/admin/commonCode" className="block">
|
|
||||||
<div className="rounded-lg border bg-card p-6 shadow-sm transition-colors hover:bg-muted">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
|
||||||
<Settings className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground">공통 코드 관리</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">시스템 공통 코드 및 설정</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 전역 파일 관리 */}
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">전역 파일 관리</h2>
|
|
||||||
<p className="text-muted-foreground">모든 페이지에서 업로드된 파일들을 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<GlobalFileViewer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,8 @@ function ScreenViewPage() {
|
||||||
initAutoFill();
|
initAutoFill();
|
||||||
}, [layout, user]);
|
}, [layout, user]);
|
||||||
|
|
||||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화
|
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||||
|
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
@ -262,13 +263,12 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 측정
|
// 초기 측정 (한 번만 실행)
|
||||||
const timer = setTimeout(updateScale, 100);
|
const timer = setTimeout(updateScale, 100);
|
||||||
|
|
||||||
window.addEventListener("resize", updateScale);
|
// resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
window.removeEventListener("resize", updateScale);
|
|
||||||
};
|
};
|
||||||
}, [layout, isMobile]);
|
}, [layout, isMobile]);
|
||||||
|
|
||||||
|
|
@ -309,7 +309,7 @@ function ScreenViewPage() {
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="bg-background flex h-full w-full items-center justify-center overflow-auto"
|
className="bg-background h-full w-full overflow-auto p-3"
|
||||||
>
|
>
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
{!layoutReady && (
|
{!layoutReady && (
|
||||||
|
|
@ -334,7 +334,7 @@ function ScreenViewPage() {
|
||||||
maxHeight: `${screenHeight}px`,
|
maxHeight: `${screenHeight}px`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transform: `scale(${scale})`,
|
transform: `scale(${scale})`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "top left",
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -197,14 +197,14 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
const inputTypeOption = INPUT_TYPE_OPTIONS.find((opt) => opt.value === column.inputType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
컬럼 추가 - {tableName}
|
컬럼 추가 - {tableName}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 검증 오류 표시 */}
|
{/* 검증 오류 표시 */}
|
||||||
|
|
@ -346,7 +346,7 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -365,8 +365,8 @@ export function AddColumnModal({ isOpen, onClose, tableName, onSuccess }: AddCol
|
||||||
"컬럼 추가"
|
"컬럼 추가"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ export default function AdvancedBatchModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
|
||||||
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -169,13 +169,13 @@ export default function BatchJobModal({
|
||||||
// 상태 제거 - 필요없음
|
// 상태 제거 - 필요없음
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
{job ? "배치 작업 수정" : "새 배치 작업"}
|
{job ? "배치 작업 수정" : "새 배치 작업"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
|
|
@ -344,7 +344,7 @@ export default function BatchJobModal({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -360,9 +360,9 @@ export default function BatchJobModal({
|
||||||
>
|
>
|
||||||
{isLoading ? "저장 중..." : "저장"}
|
{isLoading ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -164,11 +164,11 @@ export function CodeCategoryFormModal({
|
||||||
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
|
const isLoading = createCategoryMutation.isPending || updateCategoryMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</ResizableDialogTitle>
|
<DialogTitle className="text-base sm:text-lg">{isEditing ? "카테고리 수정" : "새 카테고리"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||||
{/* 카테고리 코드 */}
|
{/* 카테고리 코드 */}
|
||||||
|
|
@ -383,7 +383,7 @@ export function CodeCategoryFormModal({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
@ -153,11 +153,11 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
const isLoading = createCodeMutation.isPending || updateCodeMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</ResizableDialogTitle>
|
<DialogTitle className="text-base sm:text-lg">{isEditing ? "코드 수정" : "새 코드"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||||
{/* 코드값 */}
|
{/* 코드값 */}
|
||||||
|
|
@ -328,7 +328,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
|
||||||
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -164,13 +164,13 @@ export default function CollectionConfigModal({
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>
|
<DialogTitle>
|
||||||
{config ? "수집 설정 수정" : "새 수집 설정"}
|
{config ? "수집 설정 수정" : "새 수집 설정"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
|
|
@ -331,16 +331,16 @@ export default function CollectionConfigModal({
|
||||||
<Label htmlFor="is_active">활성화</Label>
|
<Label htmlFor="is_active">활성화</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isLoading}>
|
||||||
{isLoading ? "저장 중..." : "저장"}
|
{isLoading ? "저장 중..." : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber";
|
||||||
|
|
||||||
|
|
@ -111,8 +111,8 @@ export function CompanyFormModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
<Dialog open={modalState.isOpen} onOpenChange={handleCancel}>
|
||||||
<ResizableDialogContent
|
<DialogContent
|
||||||
className="sm:max-w-[425px]"
|
className="sm:max-w-[425px]"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
defaultWidth={500}
|
defaultWidth={500}
|
||||||
|
|
@ -124,9 +124,9 @@ export function CompanyFormModal({
|
||||||
modalId="company-form"
|
modalId="company-form"
|
||||||
userId={modalState.companyCode}
|
userId={modalState.companyCode}
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</ResizableDialogTitle>
|
<DialogTitle>{isEditMode ? "회사 정보 수정" : "새 회사 등록"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
{/* 회사명 입력 (필수) */}
|
{/* 회사명 입력 (필수) */}
|
||||||
|
|
@ -255,7 +255,7 @@ export function CompanyFormModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isSaving}>
|
<Button variant="outline" onClick={handleCancel} disabled={isLoading || isSaving}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -273,8 +273,8 @@ export function CompanyFormModal({
|
||||||
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
{(isLoading || isSaving) && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||||
{isEditMode ? "수정" : "등록"}
|
{isEditMode ? "수정" : "등록"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -321,20 +321,20 @@ export function CreateTableModal({
|
||||||
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
const isFormValid = !tableNameError && tableName && columns.some((col) => col.name && col.inputType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
{isDuplicateMode ? "테이블 복제" : "새 테이블 생성"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
{isDuplicateMode
|
{isDuplicateMode
|
||||||
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
|
||||||
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."
|
||||||
}
|
}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 테이블 기본 정보 */}
|
{/* 테이블 기본 정보 */}
|
||||||
|
|
@ -452,7 +452,7 @@ export function CreateTableModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -482,8 +482,8 @@ export function CreateTableModal({
|
||||||
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
isDuplicateMode ? "복제 생성" : "테이블 생성"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogFooter
|
DialogFooter
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -148,14 +148,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-7xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-7xl overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
DDL 실행 로그 및 통계
|
DDL 실행 로그 및 통계
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="logs" className="w-full">
|
<Tabs defaultValue="logs" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
|
@ -407,7 +407,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -266,13 +266,13 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-2xl">
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
|
{editingConfig ? "외부 호출 설정 편집" : "새 외부 호출 설정"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
|
<div className="max-h-[60vh] space-y-4 overflow-y-auto sm:space-y-6">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
|
|
@ -564,7 +564,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -580,8 +580,8 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
|
||||||
>
|
>
|
||||||
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
|
{loading ? "저장 중..." : editingConfig ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
ExternalDbConnectionAPI,
|
ExternalDbConnectionAPI,
|
||||||
|
|
@ -311,13 +311,13 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
|
|
@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -623,8 +623,8 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||||
>
|
>
|
||||||
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -66,11 +66,11 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</ResizableDialogTitle>
|
<DialogTitle>{keyData ? "언어 키 수정" : "새 언어 키 추가"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="companyCode">회사</Label>
|
<Label htmlFor="companyCode">회사</Label>
|
||||||
|
|
@ -131,7 +131,7 @@ export default function LangKeyModal({ isOpen, onClose, onSave, keyData, compani
|
||||||
<Button type="submit">{keyData ? "수정" : "추가"}</Button>
|
<Button type="submit">{keyData ? "수정" : "추가"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -68,11 +68,11 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</ResizableDialogTitle>
|
<DialogTitle>{languageData ? "언어 수정" : "새 언어 추가"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -141,8 +141,8 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
|
||||||
<Button type="submit">{languageData ? "수정" : "추가"}</Button>
|
<Button type="submit">{languageData ? "수정" : "추가"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
|
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
@ -225,14 +225,14 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</ResizableDialogDescription>
|
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 단계 표시기 */}
|
{/* 단계 표시기 */}
|
||||||
<div className="mb-6 flex items-center justify-center">
|
<div className="mb-6 flex items-center justify-center">
|
||||||
|
|
@ -499,7 +499,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
{step !== "basic" && !generationResult && (
|
{step !== "basic" && !generationResult && (
|
||||||
<Button variant="outline" onClick={handleBack}>
|
<Button variant="outline" onClick={handleBack}>
|
||||||
이전
|
이전
|
||||||
|
|
@ -527,8 +527,8 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="outline" onClick={handleClose}>
|
||||||
{generationResult?.success ? "완료" : "취소"}
|
{generationResult?.success ? "완료" : "취소"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle
|
DialogTitle
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -684,15 +684,15 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>
|
<DialogTitle>
|
||||||
{isEdit
|
{isEdit
|
||||||
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
? getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_MODIFY_TITLE)
|
||||||
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
: getText(MENU_MANAGEMENT_KEYS.MODAL_MENU_REGISTER_TITLE)}
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -1067,7 +1067,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
ExternalRestApiConnectionAPI,
|
ExternalRestApiConnectionAPI,
|
||||||
|
|
@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [retryDelay, setRetryDelay] = useState(1000);
|
const [retryDelay, setRetryDelay] = useState(1000);
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setRetryCount(connection.retry_count || 0);
|
setRetryCount(connection.retry_count || 0);
|
||||||
setRetryDelay(connection.retry_delay || 1000);
|
setRetryDelay(connection.retry_delay || 1000);
|
||||||
setIsActive(connection.is_active === "Y");
|
setIsActive(connection.is_active === "Y");
|
||||||
|
setSaveToHistory(connection.save_to_history === "Y");
|
||||||
|
|
||||||
// 테스트 초기값 설정
|
// 테스트 초기값 설정
|
||||||
setTestEndpoint("");
|
setTestEndpoint("");
|
||||||
|
|
@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
setRetryDelay(1000);
|
setRetryDelay(1000);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
|
setSaveToHistory(false);
|
||||||
|
|
||||||
// 테스트 초기값 설정
|
// 테스트 초기값 설정
|
||||||
setTestEndpoint("");
|
setTestEndpoint("");
|
||||||
|
|
@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
retry_delay: retryDelay,
|
retry_delay: retryDelay,
|
||||||
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
||||||
is_active: isActive ? "Y" : "N",
|
is_active: isActive ? "Y" : "N",
|
||||||
|
save_to_history: saveToHistory ? "Y" : "N",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("저장하려는 데이터:", {
|
console.log("저장하려는 데이터:", {
|
||||||
|
|
@ -271,13 +275,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
<DialogContent className="flex max-h-[90vh] max-w-3xl flex-col">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader className="flex-shrink-0">
|
||||||
<ResizableDialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</ResizableDialogTitle>
|
<DialogTitle>{connection ? "REST API 연결 수정" : "새 REST API 연결 추가"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="flex-1 space-y-6 overflow-y-auto py-4 pr-2">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||||
|
|
@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
활성 상태
|
활성 상태
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="save-to-history" checked={saveToHistory} onCheckedChange={setSaveToHistory} />
|
||||||
|
<Label htmlFor="save-to-history" className="cursor-pointer">
|
||||||
|
위치 이력 저장
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(지도 위젯에서 이 API 데이터를 vehicle_location_history에 저장)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 헤더 관리 */}
|
{/* 헤더 관리 */}
|
||||||
|
|
@ -574,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter className="flex-shrink-0 border-t pt-4">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
취소
|
취소
|
||||||
|
|
@ -583,8 +597,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{saving ? "저장 중..." : connection ? "수정" : "생성"}
|
{saving ? "저장 중..." : connection ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
import { roleAPI, RoleGroup } from "@/lib/api/role";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
@ -71,11 +71,11 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">권한 그룹 삭제</ResizableDialogTitle>
|
<DialogTitle className="text-base sm:text-lg">권한 그룹 삭제</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 경고 메시지 */}
|
{/* 경고 메시지 */}
|
||||||
|
|
@ -133,7 +133,7 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -150,8 +150,8 @@ export function RoleDeleteModal({ isOpen, onClose, onSuccess, role }: RoleDelete
|
||||||
>
|
>
|
||||||
{isLoading ? "삭제중..." : "삭제"}
|
{isLoading ? "삭제중..." : "삭제"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { DualListBox } from "@/components/common/DualListBox";
|
import { DualListBox } from "@/components/common/DualListBox";
|
||||||
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
import { MenuPermissionsTable } from "./MenuPermissionsTable";
|
||||||
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
|
|
||||||
interface RoleDetailManagementProps {
|
interface RoleDetailManagementProps {
|
||||||
roleId: string;
|
roleId: string;
|
||||||
|
|
@ -25,6 +26,7 @@ interface RoleDetailManagementProps {
|
||||||
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { refreshMenus } = useMenu();
|
||||||
|
|
||||||
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
|
@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alert("멤버가 성공적으로 저장되었습니다.");
|
alert("멤버가 성공적으로 저장되었습니다.");
|
||||||
loadMembers(); // 새로고침
|
loadMembers(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
|
||||||
|
await refreshMenus();
|
||||||
} else {
|
} else {
|
||||||
alert(response.message || "멤버 저장에 실패했습니다.");
|
alert(response.message || "멤버 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingMembers(false);
|
setIsSavingMembers(false);
|
||||||
}
|
}
|
||||||
}, [roleGroup, selectedUsers, loadMembers]);
|
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
|
||||||
|
|
||||||
// 메뉴 권한 저장 핸들러
|
// 메뉴 권한 저장 핸들러
|
||||||
const handleSavePermissions = useCallback(async () => {
|
const handleSavePermissions = useCallback(async () => {
|
||||||
|
|
@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
alert("메뉴 권한이 성공적으로 저장되었습니다.");
|
||||||
loadMenuPermissions(); // 새로고침
|
loadMenuPermissions(); // 새로고침
|
||||||
|
|
||||||
|
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
|
||||||
|
await refreshMenus();
|
||||||
} else {
|
} else {
|
||||||
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingPermissions(false);
|
setIsSavingPermissions(false);
|
||||||
}
|
}
|
||||||
}, [roleGroup, menuPermissions, loadMenuPermissions]);
|
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -184,11 +184,11 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</ResizableDialogTitle>
|
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "권한 그룹 수정" : "권한 그룹 생성"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* 권한 그룹명 */}
|
{/* 권한 그룹명 */}
|
||||||
|
|
@ -359,7 +359,7 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -375,8 +375,8 @@ export function RoleFormModal({ isOpen, onClose, onSuccess, editingRole }: RoleF
|
||||||
>
|
>
|
||||||
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
|
{isLoading ? "처리중..." : isEditMode ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
import { useState, useEffect, ChangeEvent } from "react";
|
import { useState, useEffect, ChangeEvent } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -179,14 +179,14 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto" aria-describedby="modal-description">
|
<DialogContent className="max-h-[90vh] max-w-5xl overflow-hidden" aria-describedby="modal-description">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{connectionName} - SQL 쿼리 실행</ResizableDialogTitle>
|
<DialogTitle>{connectionName} - SQL 쿼리 실행</DialogTitle>
|
||||||
<ResizableDialogDescription>
|
<DialogDescription>
|
||||||
데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다.
|
데이터베이스에 대해 SQL SELECT 쿼리를 실행하고 결과를 확인할 수 있습니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 쿼리 입력 영역 */}
|
{/* 쿼리 입력 영역 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -228,7 +228,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
<div className="bg-muted/50 space-y-4 rounded-md border p-4">
|
<div className="bg-muted/50 space-y-4 rounded-md border p-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-sm font-medium">사용 가능한 테이블</h3>
|
<h3 className="mb-2 text-sm font-medium">사용 가능한 테이블</h3>
|
||||||
<div className="max-h-[200px] overflow-y-auto">
|
<div className="max-h-[200px] overflow-hidden">
|
||||||
<div className="space-y-2 pr-2">
|
<div className="space-y-2 pr-2">
|
||||||
{tables.map((table) => (
|
{tables.map((table) => (
|
||||||
<div key={table.table_name} className="bg-card rounded-lg border p-3 shadow-sm">
|
<div key={table.table_name} className="bg-card rounded-lg border p-3 shadow-sm">
|
||||||
|
|
@ -263,7 +263,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
{loadingColumns ? (
|
{loadingColumns ? (
|
||||||
<div className="text-muted-foreground text-sm">컬럼 정보 로딩 중...</div>
|
<div className="text-muted-foreground text-sm">컬럼 정보 로딩 중...</div>
|
||||||
) : selectedTableColumns.length > 0 ? (
|
) : selectedTableColumns.length > 0 ? (
|
||||||
<div className="max-h-[200px] overflow-y-auto">
|
<div className="max-h-[200px] overflow-hidden">
|
||||||
<div className="bg-card rounded-lg border shadow-sm">
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -332,7 +332,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
|
|
||||||
{/* 결과 그리드 */}
|
{/* 결과 그리드 */}
|
||||||
<div className="bg-card rounded-md border">
|
<div className="bg-card rounded-md border">
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-hidden">
|
||||||
<div className="inline-block min-w-full align-middle">
|
<div className="inline-block min-w-full align-middle">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
|
|
@ -378,7 +378,7 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogFooter
|
DialogFooter
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -126,14 +126,14 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<History className="h-5 w-5" />
|
<History className="h-5 w-5" />
|
||||||
{tableName} - 변경 이력
|
{tableName} - 변경 이력
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 필터 영역 */}
|
{/* 필터 영역 */}
|
||||||
<div className="space-y-3 rounded-lg border p-4">
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
|
@ -261,7 +261,7 @@ export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react";
|
import { Upload, Download, FileText, AlertCircle, CheckCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTemplates } from "@/hooks/admin/useTemplates";
|
import { useTemplates } from "@/hooks/admin/useTemplates";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -32,11 +32,11 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className={getTypeColor()}>{title}</ResizableDialogTitle>
|
<DialogTitle className={getTypeColor()}>{title}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<p className="text-muted-foreground text-sm">{message}</p>
|
<p className="text-muted-foreground text-sm">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,8 +45,8 @@ function AlertModal({ isOpen, onClose, title, message, type = "info" }: AlertMod
|
||||||
확인
|
확인
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,11 +441,11 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</ResizableDialogTitle>
|
<DialogTitle>{isEditMode ? "사용자 정보 수정" : "사용자 등록"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
|
|
@ -684,8 +684,8 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
||||||
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
|
{isLoading ? "처리중..." : isEditMode ? "수정" : "등록"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 알림 모달 */}
|
{/* 알림 모달 */}
|
||||||
<AlertModal
|
<AlertModal
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -152,17 +152,17 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col">
|
||||||
<ResizableDialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<ResizableDialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<User className="h-5 w-5" />
|
<User className="h-5 w-5" />
|
||||||
사용자 관리 이력
|
사용자 관리 이력
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
{userName} ({userId})의 변경이력을 조회합니다.
|
{userName} ({userId})의 변경이력을 조회합니다.
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{/* 로딩 상태 */}
|
{/* 로딩 상태 */}
|
||||||
|
|
@ -254,7 +254,7 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist
|
||||||
닫기
|
닫기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/resizable-dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -127,11 +127,11 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
||||||
if (!userId) return null;
|
if (!userId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>비밀번호 초기화</ResizableDialogTitle>
|
<DialogTitle>비밀번호 초기화</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4" onKeyDown={handleKeyDown}>
|
<div className="space-y-4" onKeyDown={handleKeyDown}>
|
||||||
{/* 대상 사용자 정보 */}
|
{/* 대상 사용자 정보 */}
|
||||||
|
|
@ -215,7 +215,7 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
||||||
{isLoading ? "처리중..." : "초기화"}
|
{isLoading ? "처리중..." : "초기화"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
{/* 알림 모달 */}
|
{/* 알림 모달 */}
|
||||||
<AlertModal
|
<AlertModal
|
||||||
|
|
@ -225,6 +225,6 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
|
||||||
title={alertState.title}
|
title={alertState.title}
|
||||||
message={alertState.message}
|
message={alertState.message}
|
||||||
/>
|
/>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -639,23 +639,23 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 저장 성공 모달 */}
|
{/* 저장 성공 모달 */}
|
||||||
<ResizableDialog
|
<Dialog
|
||||||
open={successModalOpen}
|
open={successModalOpen}
|
||||||
onOpenChange={() => {
|
onOpenChange={() => {
|
||||||
setSuccessModalOpen(false);
|
setSuccessModalOpen(false);
|
||||||
router.push("/admin/dashboard");
|
router.push("/admin/dashboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResizableDialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
<div className="bg-success/10 mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<CheckCircle2 className="text-success h-6 w-6" />
|
<CheckCircle2 className="text-success h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<ResizableDialogTitle className="text-center">저장 완료</ResizableDialogTitle>
|
<DialogTitle className="text-center">저장 완료</DialogTitle>
|
||||||
<ResizableDialogDescription className="text-center">
|
<DialogDescription className="text-center">
|
||||||
대시보드가 성공적으로 저장되었습니다.
|
대시보드가 성공적으로 저장되었습니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-center pt-4">
|
<div className="flex justify-center pt-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -666,8 +666,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
확인
|
확인
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 초기화 확인 모달 */}
|
{/* 초기화 확인 모달 */}
|
||||||
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -174,11 +174,11 @@ export function DashboardSaveModal({
|
||||||
const flatMenus = flattenMenus(currentMenus);
|
const flatMenus = flattenMenus(currentMenus);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<ResizableDialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</ResizableDialogTitle>
|
<DialogTitle>{isEditing ? "대시보드 수정" : "대시보드 저장"}</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* 대시보드 이름 */}
|
{/* 대시보드 이름 */}
|
||||||
|
|
@ -312,7 +312,7 @@ export function DashboardSaveModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={loading}>
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -329,8 +329,8 @@ export function DashboardSaveModal({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
|
@ -116,14 +116,14 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ResizableDialogTitle>대시보드 저장 완료</ResizableDialogTitle>
|
<DialogTitle>대시보드 저장 완료</DialogTitle>
|
||||||
<ResizableDialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</ResizableDialogDescription>
|
<DialogDescription>'{dashboardTitle}' 대시보드가 저장되었습니다.</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -200,13 +200,13 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose}>
|
<Button variant="outline" onClick={handleClose}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleConfirm}>{assignToMenu ? "메뉴에 할당하고 완료" : "완료"}</Button>
|
<Button onClick={handleConfirm}>{assignToMenu ? "메뉴에 할당하고 완료" : "완료"}</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -296,13 +296,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
element.subtype === "custom-metric-v2" ||
|
element.subtype === "custom-metric-v2" ||
|
||||||
element.subtype === "risk-alert-v2";
|
element.subtype === "risk-alert-v2";
|
||||||
|
|
||||||
|
// 리스트 위젯이 단일 데이터 소스 UI를 사용하는 경우, dataSource를 dataSources로 변환
|
||||||
|
let finalDataSources = dataSources;
|
||||||
|
if (isMultiDataSourceWidget && element.subtype === "list-v2" && dataSources.length === 0 && dataSource.endpoint) {
|
||||||
|
// 단일 데이터 소스가 설정되어 있으면 dataSources 배열로 변환
|
||||||
|
finalDataSources = [dataSource];
|
||||||
|
}
|
||||||
|
|
||||||
// chartConfig 구성 (위젯 타입별로 다르게 처리)
|
// chartConfig 구성 (위젯 타입별로 다르게 처리)
|
||||||
let finalChartConfig = { ...chartConfig };
|
let finalChartConfig = { ...chartConfig };
|
||||||
|
|
||||||
if (isMultiDataSourceWidget) {
|
if (isMultiDataSourceWidget) {
|
||||||
finalChartConfig = {
|
finalChartConfig = {
|
||||||
...finalChartConfig,
|
...finalChartConfig,
|
||||||
dataSources: dataSources,
|
dataSources: finalDataSources,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,7 +332,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
|
// 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제)
|
||||||
...(isMultiDataSourceWidget
|
...(isMultiDataSourceWidget
|
||||||
? {
|
? {
|
||||||
dataSources: dataSources,
|
dataSources: finalDataSources,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,9 +137,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.type = "api"; // ⭐ 중요: type을 api로 명시
|
updates.type = "api"; // ⭐ 중요: type을 api로 명시
|
||||||
updates.method = "GET"; // 기본 메서드
|
updates.method = (connection.default_method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE") || "GET"; // 커넥션에 설정된 메서드 사용
|
||||||
updates.headers = headers;
|
updates.headers = headers;
|
||||||
updates.queryParams = queryParams;
|
updates.queryParams = queryParams;
|
||||||
|
|
||||||
|
// Request Body가 있으면 적용
|
||||||
|
if (connection.default_body) {
|
||||||
|
updates.body = connection.default_body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 커넥션 ID 저장 (백엔드에서 인증 정보 조회용)
|
||||||
|
updates.externalConnectionId = connection.id;
|
||||||
|
|
||||||
console.log("최종 업데이트:", updates);
|
console.log("최종 업데이트:", updates);
|
||||||
|
|
||||||
onChange(updates);
|
onChange(updates);
|
||||||
|
|
@ -254,6 +263,19 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 요청 메서드 결정
|
||||||
|
const requestMethod = dataSource.method || "GET";
|
||||||
|
|
||||||
|
// Request Body 파싱 (POST, PUT, PATCH인 경우)
|
||||||
|
let requestBody: any = undefined;
|
||||||
|
if (["POST", "PUT", "PATCH"].includes(requestMethod) && dataSource.body) {
|
||||||
|
try {
|
||||||
|
requestBody = JSON.parse(dataSource.body);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Request Body가 올바른 JSON 형식이 아닙니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -262,9 +284,11 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: dataSource.endpoint,
|
url: dataSource.endpoint,
|
||||||
method: "GET",
|
method: requestMethod,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
queryParams: params,
|
queryParams: params,
|
||||||
|
body: requestBody,
|
||||||
|
externalConnectionId: dataSource.externalConnectionId, // DB 토큰 등 인증 정보 조회용
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,10 +338,23 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
if (dataSource.jsonPath) {
|
if (dataSource.jsonPath) {
|
||||||
const paths = dataSource.jsonPath.split(".");
|
const paths = dataSource.jsonPath.split(".");
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
if (data && typeof data === "object" && path in data) {
|
// 배열인 경우 인덱스 접근, 객체인 경우 키 접근
|
||||||
data = data[path];
|
if (data === null || data === undefined) {
|
||||||
|
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다 (null/undefined)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
// 배열인 경우 숫자 인덱스로 접근 시도
|
||||||
|
const index = parseInt(path);
|
||||||
|
if (!isNaN(index) && index >= 0 && index < data.length) {
|
||||||
|
data = data[index];
|
||||||
|
} else {
|
||||||
|
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 배열 인덱스 "${path}"를 찾을 수 없습니다`);
|
||||||
|
}
|
||||||
|
} else if (typeof data === "object" && path in data) {
|
||||||
|
data = (data as Record<string, any>)[path];
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 "${path}" 키를 찾을 수 없습니다`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +368,16 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
|
|
||||||
// 컬럼 추출 및 타입 분석
|
// 컬럼 추출 및 타입 분석
|
||||||
const firstRow = rows[0];
|
const firstRow = rows[0];
|
||||||
|
|
||||||
|
// firstRow가 null이거나 객체가 아닌 경우 처리
|
||||||
|
if (firstRow === null || firstRow === undefined) {
|
||||||
|
throw new Error("API 응답의 첫 번째 행이 비어있습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof firstRow !== "object" || Array.isArray(firstRow)) {
|
||||||
|
throw new Error("API 응답 데이터가 올바른 객체 형식이 아닙니다");
|
||||||
|
}
|
||||||
|
|
||||||
const columns = Object.keys(firstRow);
|
const columns = Object.keys(firstRow);
|
||||||
|
|
||||||
// 각 컬럼의 타입 분석
|
// 각 컬럼의 타입 분석
|
||||||
|
|
@ -400,21 +447,54 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
<p className="text-[11px] text-muted-foreground">저장한 REST API 설정을 불러올 수 있습니다</p>
|
<p className="text-[11px] text-muted-foreground">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API URL */}
|
{/* HTTP 메서드 및 API URL */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs font-medium text-foreground">API URL *</Label>
|
<Label className="text-xs font-medium text-foreground">API URL *</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
type="url"
|
<Select
|
||||||
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
|
value={dataSource.method || "GET"}
|
||||||
value={dataSource.endpoint || ""}
|
onValueChange={(value) => onChange({ method: value as "GET" | "POST" | "PUT" | "PATCH" | "DELETE" })}
|
||||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
>
|
||||||
className="h-8 text-xs"
|
<SelectTrigger className="h-8 w-24 text-xs">
|
||||||
/>
|
<SelectValue placeholder="GET" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[9999]">
|
||||||
|
<SelectItem value="GET" className="text-xs">GET</SelectItem>
|
||||||
|
<SelectItem value="POST" className="text-xs">POST</SelectItem>
|
||||||
|
<SelectItem value="PUT" className="text-xs">PUT</SelectItem>
|
||||||
|
<SelectItem value="PATCH" className="text-xs">PATCH</SelectItem>
|
||||||
|
<SelectItem value="DELETE" className="text-xs">DELETE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://api.example.com/data"
|
||||||
|
value={dataSource.endpoint || ""}
|
||||||
|
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)
|
전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Request Body (POST, PUT, PATCH인 경우) */}
|
||||||
|
{["POST", "PUT", "PATCH"].includes(dataSource.method || "") && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium text-foreground">Request Body (JSON)</Label>
|
||||||
|
<textarea
|
||||||
|
placeholder='{"key": "value"}'
|
||||||
|
value={dataSource.body || ""}
|
||||||
|
onChange={(e) => onChange({ body: e.target.value })}
|
||||||
|
className="h-24 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
JSON 형식으로 요청 본문을 입력하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 쿼리 파라미터 */}
|
{/* 쿼리 파라미터 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -544,6 +624,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 자동 새로고침 (HTTP Polling) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-medium text-foreground">자동 새로고침 간격</Label>
|
||||||
|
<Select
|
||||||
|
value={(dataSource.refreshInterval || 0).toString()}
|
||||||
|
onValueChange={(value) => onChange({ refreshInterval: parseInt(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="간격 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[9999]">
|
||||||
|
<SelectItem value="0" className="text-xs">없음 (수동)</SelectItem>
|
||||||
|
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||||
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
|
<SelectItem value="300" className="text-xs">5분</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
설정된 간격마다 자동으로 API를 호출하여 데이터를 갱신합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
{/* 테스트 버튼 */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
|
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
|
||||||
|
|
@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 위치 이력 저장 설정 (지도 위젯용) */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="save-to-history" className="text-xs font-semibold cursor-pointer">
|
||||||
|
위치 이력 저장
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="save-to-history"
|
||||||
|
checked={dataSource.saveToHistory || false}
|
||||||
|
onCheckedChange={(checked) => onChange({ saveToHistory: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
||||||
{testResult?.success && availableColumns.length > 0 && (
|
{testResult?.success && availableColumns.length > 0 && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,9 @@ export interface ChartDataSource {
|
||||||
label: string; // 표시할 한글명 (예: 차량 번호)
|
label: string; // 표시할 한글명 (예: 차량 번호)
|
||||||
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
// REST API 위치 데이터 저장 설정 (MapTestWidgetV2용)
|
||||||
|
saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
|
|
@ -376,6 +379,47 @@ export interface ListWidgetConfig {
|
||||||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||||
compactMode: boolean; // 압축 모드 (기본: false)
|
compactMode: boolean; // 압축 모드 (기본: false)
|
||||||
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
||||||
|
// 행 클릭 팝업 설정
|
||||||
|
rowDetailPopup?: RowDetailPopupConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행 상세 팝업 설정
|
||||||
|
export interface RowDetailPopupConfig {
|
||||||
|
enabled: boolean; // 팝업 활성화 여부
|
||||||
|
title?: string; // 팝업 제목 (기본: "상세 정보")
|
||||||
|
// 추가 데이터 조회 설정
|
||||||
|
additionalQuery?: {
|
||||||
|
enabled: boolean;
|
||||||
|
tableName: string; // 조회할 테이블명 (예: vehicles)
|
||||||
|
matchColumn: string; // 매칭할 컬럼 (예: id)
|
||||||
|
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
|
||||||
|
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
|
||||||
|
displayColumns?: DisplayColumnConfig[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표시 컬럼 설정
|
||||||
|
export interface DisplayColumnConfig {
|
||||||
|
column: string; // DB 컬럼명
|
||||||
|
label: string; // 표시 라벨 (사용자 정의)
|
||||||
|
// 필드 그룹 설정
|
||||||
|
fieldGroups?: FieldGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 그룹 (팝업 내 섹션)
|
||||||
|
export interface FieldGroup {
|
||||||
|
id: string;
|
||||||
|
title: string; // 그룹 제목 (예: "운행 정보")
|
||||||
|
icon?: string; // 아이콘 (예: "truck", "clock")
|
||||||
|
color?: "blue" | "orange" | "green" | "red" | "purple" | "gray";
|
||||||
|
fields: FieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 설정
|
||||||
|
export interface FieldConfig {
|
||||||
|
column: string; // DB 컬럼명
|
||||||
|
label: string; // 표시 라벨
|
||||||
|
format?: "text" | "number" | "date" | "datetime" | "currency" | "boolean" | "distance" | "duration";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리스트 컬럼
|
// 리스트 컬럼
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 6. 자동 새로고침 간격 */}
|
{/* 6. 소수점 자릿수 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">소수점 자릿수</Label>
|
||||||
|
<Select
|
||||||
|
value={(config.decimals ?? 0).toString()}
|
||||||
|
onValueChange={(value) => onConfigChange({ decimals: parseInt(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="자릿수 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0" className="text-xs">정수 (0자리)</SelectItem>
|
||||||
|
<SelectItem value="1" className="text-xs">소수점 1자리</SelectItem>
|
||||||
|
<SelectItem value="2" className="text-xs">소수점 2자리</SelectItem>
|
||||||
|
<SelectItem value="3" className="text-xs">소수점 3자리</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
표시할 소수점 자릿수 (평균, 비율 등에 유용)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7. 자동 새로고침 간격 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">자동 새로고침</Label>
|
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ListWidgetConfig, QueryResult } from "../types";
|
import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor";
|
||||||
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
import { ListTableOptions } from "../widgets/list-widget/ListTableOptions";
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronUp, X, Check } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
interface ListWidgetSectionProps {
|
interface ListWidgetSectionProps {
|
||||||
queryResult: QueryResult | null;
|
queryResult: QueryResult | null;
|
||||||
|
|
@ -16,8 +23,91 @@ interface ListWidgetSectionProps {
|
||||||
* 리스트 위젯 설정 섹션
|
* 리스트 위젯 설정 섹션
|
||||||
* - 컬럼 설정
|
* - 컬럼 설정
|
||||||
* - 테이블 옵션
|
* - 테이블 옵션
|
||||||
|
* - 행 클릭 팝업 설정
|
||||||
*/
|
*/
|
||||||
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
|
export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) {
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 팝업 설정 초기화
|
||||||
|
const popupConfig = config.rowDetailPopup || {
|
||||||
|
enabled: false,
|
||||||
|
title: "상세 정보",
|
||||||
|
additionalQuery: { enabled: false, tableName: "", matchColumn: "" },
|
||||||
|
fieldGroups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 팝업 설정 업데이트 헬퍼
|
||||||
|
const updatePopupConfig = (updates: Partial<typeof popupConfig>) => {
|
||||||
|
onConfigChange({
|
||||||
|
rowDetailPopup: { ...popupConfig, ...updates },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 추가
|
||||||
|
const addFieldGroup = () => {
|
||||||
|
const newGroup: FieldGroup = {
|
||||||
|
id: `group-${Date.now()}`,
|
||||||
|
title: "새 그룹",
|
||||||
|
icon: "info",
|
||||||
|
color: "gray",
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: [...(popupConfig.fieldGroups || []), newGroup],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 삭제
|
||||||
|
const removeFieldGroup = (groupId: string) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).filter((g) => g.id !== groupId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 업데이트
|
||||||
|
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) => (g.id === groupId ? { ...g, ...updates } : g)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 추가
|
||||||
|
const addField = (groupId: string) => {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
column: "",
|
||||||
|
label: "",
|
||||||
|
format: "text",
|
||||||
|
};
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: [...g.fields, newField] } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 삭제
|
||||||
|
const removeField = (groupId: string, fieldIndex: number) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: g.fields.filter((_, i) => i !== fieldIndex) } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 업데이트
|
||||||
|
const updateField = (groupId: string, fieldIndex: number, updates: Partial<FieldConfig>) => {
|
||||||
|
updatePopupConfig({
|
||||||
|
fieldGroups: (popupConfig.fieldGroups || []).map((g) =>
|
||||||
|
g.id === groupId ? { ...g, fields: g.fields.map((f, i) => (i === fieldIndex ? { ...f, ...updates } : f)) } : g,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 확장/축소 토글
|
||||||
|
const toggleGroupExpand = (groupId: string) => {
|
||||||
|
setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||||
|
|
@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 행 클릭 팝업 설정 */}
|
||||||
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold">행 클릭 팝업</Label>
|
||||||
|
<Switch
|
||||||
|
checked={popupConfig.enabled}
|
||||||
|
onCheckedChange={(enabled) => updatePopupConfig({ enabled })}
|
||||||
|
aria-label="행 클릭 팝업 활성화"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{popupConfig.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 팝업 제목 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">팝업 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.title || ""}
|
||||||
|
onChange={(e) => updatePopupConfig({ title: e.target.value })}
|
||||||
|
placeholder="상세 정보"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 데이터 조회 설정 */}
|
||||||
|
<div className="space-y-2 rounded border p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">추가 데이터 조회</Label>
|
||||||
|
<Switch
|
||||||
|
checked={popupConfig.additionalQuery?.enabled || false}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label="추가 데이터 조회 활성화"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{popupConfig.additionalQuery?.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.tableName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="vehicles"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">매칭 컬럼 (조회 테이블)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.matchColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="id"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컬럼 (클릭한 행)</Label>
|
||||||
|
<Input
|
||||||
|
value={popupConfig.additionalQuery?.sourceColumn || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="비워두면 매칭 컬럼과 동일"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
|
||||||
|
<span className="truncate">
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
|
||||||
|
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
|
||||||
|
: "전체 표시 (클릭하여 선택)"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-2" align="start">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">컬럼 선택</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{/* 쿼리 결과 컬럼 목록 */}
|
||||||
|
{queryResult?.columns.map((col) => {
|
||||||
|
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
|
||||||
|
const existingConfig = currentColumns.find((c) =>
|
||||||
|
typeof c === 'object' ? c.column === col : c === col
|
||||||
|
);
|
||||||
|
const isSelected = !!existingConfig;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = isSelected
|
||||||
|
? currentColumns.filter((c) =>
|
||||||
|
typeof c === 'object' ? c.column !== col : c !== col
|
||||||
|
)
|
||||||
|
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} className="h-3 w-3" />
|
||||||
|
<span className="text-xs">{col}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!queryResult?.columns || queryResult.columns.length === 0) && (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
쿼리를 먼저 실행해주세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">비워두면 모든 컬럼이 표시됩니다</p>
|
||||||
|
|
||||||
|
{/* 선택된 컬럼 라벨 편집 */}
|
||||||
|
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Label className="text-xs">컬럼 라벨 설정</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return (
|
||||||
|
<div key={column} className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground w-24 truncate text-xs" title={column}>
|
||||||
|
{column}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
|
||||||
|
newColumns[index] = { column, label: e.target.value };
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
|
||||||
|
(c) => (typeof c === 'object' ? c.column : c) !== column
|
||||||
|
);
|
||||||
|
updatePopupConfig({
|
||||||
|
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 그룹 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드 그룹 (선택사항)</Label>
|
||||||
|
<Button variant="outline" size="sm" onClick={addFieldGroup} className="h-7 gap-1 text-xs">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">설정하지 않으면 모든 필드가 자동으로 표시됩니다.</p>
|
||||||
|
|
||||||
|
{/* 필드 그룹 목록 */}
|
||||||
|
{(popupConfig.fieldGroups || []).map((group) => (
|
||||||
|
<div key={group.id} className="rounded border p-2">
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroupExpand(group.id)}
|
||||||
|
className="flex flex-1 items-center gap-2 text-left"
|
||||||
|
>
|
||||||
|
{expandedGroups[group.id] ? (
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">{group.title || "새 그룹"}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">({group.fields.length}개 필드)</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeFieldGroup(group.id)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 상세 (확장 시) */}
|
||||||
|
{expandedGroups[group.id] && (
|
||||||
|
<div className="mt-2 space-y-2 border-t pt-2">
|
||||||
|
{/* 그룹 제목 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">제목</Label>
|
||||||
|
<Input
|
||||||
|
value={group.title}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">색상</Label>
|
||||||
|
<Select
|
||||||
|
value={group.color || "gray"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFieldGroup(group.id, {
|
||||||
|
color: value as "blue" | "orange" | "green" | "red" | "purple" | "gray",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="gray">회색</SelectItem>
|
||||||
|
<SelectItem value="blue">파랑</SelectItem>
|
||||||
|
<SelectItem value="orange">주황</SelectItem>
|
||||||
|
<SelectItem value="green">초록</SelectItem>
|
||||||
|
<SelectItem value="red">빨강</SelectItem>
|
||||||
|
<SelectItem value="purple">보라</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 아이콘 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">아이콘</Label>
|
||||||
|
<Select
|
||||||
|
value={group.icon || "info"}
|
||||||
|
onValueChange={(value) => updateFieldGroup(group.id, { icon: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="info">정보</SelectItem>
|
||||||
|
<SelectItem value="truck">트럭</SelectItem>
|
||||||
|
<SelectItem value="clock">시계</SelectItem>
|
||||||
|
<SelectItem value="map">지도</SelectItem>
|
||||||
|
<SelectItem value="package">박스</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">필드</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addField(group.id)}
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.fields.map((field, fieldIndex) => (
|
||||||
|
<div key={fieldIndex} className="flex items-center gap-1 rounded bg-muted/50 p-1">
|
||||||
|
<Input
|
||||||
|
value={field.column}
|
||||||
|
onChange={(e) => updateField(group.id, fieldIndex, { column: e.target.value })}
|
||||||
|
placeholder="컬럼명"
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => updateField(group.id, fieldIndex, { label: e.target.value })}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-6 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={field.format || "text"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateField(group.id, fieldIndex, {
|
||||||
|
format: value as FieldConfig["format"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-20 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="date">날짜</SelectItem>
|
||||||
|
<SelectItem value="datetime">날짜시간</SelectItem>
|
||||||
|
<SelectItem value="currency">통화</SelectItem>
|
||||||
|
<SelectItem value="distance">거리</SelectItem>
|
||||||
|
<SelectItem value="duration">시간</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeField(group.id, fieldIndex)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
import { Truck, Clock, MapPin, Package, Info } from "lucide-react";
|
||||||
|
|
||||||
interface ListWidgetProps {
|
interface ListWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 행 상세 팝업 상태
|
||||||
|
const [detailPopupOpen, setDetailPopupOpen] = useState(false);
|
||||||
|
const [detailPopupData, setDetailPopupData] = useState<Record<string, any> | null>(null);
|
||||||
|
const [detailPopupLoading, setDetailPopupLoading] = useState(false);
|
||||||
|
const [additionalDetailData, setAdditionalDetailData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
const config = element.listConfig || {
|
const config = element.listConfig || {
|
||||||
columnMode: "auto",
|
columnMode: "auto",
|
||||||
viewMode: "table",
|
viewMode: "table",
|
||||||
|
|
@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
cardColumns: 3,
|
cardColumns: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 행 클릭 핸들러 - 팝업 열기
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
async (row: Record<string, any>) => {
|
||||||
|
// 팝업이 비활성화되어 있으면 무시
|
||||||
|
if (!config.rowDetailPopup?.enabled) return;
|
||||||
|
|
||||||
|
setDetailPopupData(row);
|
||||||
|
setDetailPopupOpen(true);
|
||||||
|
setAdditionalDetailData(null);
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
|
||||||
|
// 추가 데이터 조회 설정이 있으면 실행
|
||||||
|
const additionalQuery = config.rowDetailPopup?.additionalQuery;
|
||||||
|
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
|
||||||
|
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
|
||||||
|
const matchValue = row[sourceColumn];
|
||||||
|
|
||||||
|
if (matchValue !== undefined && matchValue !== null) {
|
||||||
|
setDetailPopupLoading(true);
|
||||||
|
try {
|
||||||
|
const query = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${additionalQuery.tableName}
|
||||||
|
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
const result = await dashboardApi.executeQuery(query);
|
||||||
|
|
||||||
|
if (result.success && result.rows.length > 0) {
|
||||||
|
setAdditionalDetailData(result.rows[0]);
|
||||||
|
} else {
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("추가 데이터 로드 실패:", error);
|
||||||
|
setAdditionalDetailData({});
|
||||||
|
} finally {
|
||||||
|
setDetailPopupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.rowDetailPopup],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 값 포맷팅 함수
|
||||||
|
const formatValue = (value: any, format?: string): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case "date":
|
||||||
|
return new Date(value).toLocaleDateString("ko-KR");
|
||||||
|
case "datetime":
|
||||||
|
return new Date(value).toLocaleString("ko-KR");
|
||||||
|
case "number":
|
||||||
|
return Number(value).toLocaleString("ko-KR");
|
||||||
|
case "currency":
|
||||||
|
return `${Number(value).toLocaleString("ko-KR")}원`;
|
||||||
|
case "boolean":
|
||||||
|
return value ? "예" : "아니오";
|
||||||
|
case "distance":
|
||||||
|
return typeof value === "number" ? `${value.toFixed(1)} km` : String(value);
|
||||||
|
case "duration":
|
||||||
|
return typeof value === "number" ? `${value}분` : String(value);
|
||||||
|
default:
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아이콘 렌더링
|
||||||
|
const renderIcon = (icon?: string, color?: string) => {
|
||||||
|
const colorClass =
|
||||||
|
color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
switch (icon) {
|
||||||
|
case "truck":
|
||||||
|
return <Truck className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "clock":
|
||||||
|
return <Clock className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "map":
|
||||||
|
return <MapPin className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
case "package":
|
||||||
|
return <Package className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
default:
|
||||||
|
return <Info className={`h-4 w-4 ${colorClass}`} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 그룹 렌더링
|
||||||
|
const renderFieldGroup = (group: FieldGroup, data: Record<string, any>) => {
|
||||||
|
const colorClass =
|
||||||
|
group.color === "blue"
|
||||||
|
? "text-blue-600"
|
||||||
|
: group.color === "orange"
|
||||||
|
? "text-orange-600"
|
||||||
|
: group.color === "green"
|
||||||
|
? "text-green-600"
|
||||||
|
: group.color === "red"
|
||||||
|
? "text-red-600"
|
||||||
|
: group.color === "purple"
|
||||||
|
? "text-purple-600"
|
||||||
|
: "text-gray-600";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="rounded-lg border p-4">
|
||||||
|
<div className={`mb-3 flex items-center gap-2 text-sm font-semibold ${colorClass}`}>
|
||||||
|
{renderIcon(group.icon, group.color)}
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
|
||||||
|
{group.fields.map((field) => (
|
||||||
|
<div key={field.column} className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wide">
|
||||||
|
{field.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium break-words">{formatValue(data[field.column], field.format)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 필드 그룹 생성 (설정이 없을 경우)
|
||||||
|
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
|
||||||
|
const groups: FieldGroup[] = [];
|
||||||
|
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
|
||||||
|
|
||||||
|
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
|
||||||
|
let basicFields: { column: string; label: string }[] = [];
|
||||||
|
|
||||||
|
if (displayColumns && displayColumns.length > 0) {
|
||||||
|
// DisplayColumnConfig 형식 지원
|
||||||
|
basicFields = displayColumns
|
||||||
|
.map((colConfig) => {
|
||||||
|
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
|
||||||
|
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
|
||||||
|
return { column, label };
|
||||||
|
})
|
||||||
|
.filter((item) => item.column in row);
|
||||||
|
} else {
|
||||||
|
// 전체 컬럼
|
||||||
|
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: "basic",
|
||||||
|
title: "기본 정보",
|
||||||
|
icon: "info",
|
||||||
|
color: "gray",
|
||||||
|
fields: basicFields.map((item) => ({
|
||||||
|
column: item.column,
|
||||||
|
label: item.label,
|
||||||
|
format: "text",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
|
||||||
|
if (additional && Object.keys(additional).length > 0) {
|
||||||
|
// 운행 정보
|
||||||
|
if (additional.last_trip_start || additional.last_trip_end) {
|
||||||
|
groups.push({
|
||||||
|
id: "trip",
|
||||||
|
title: "운행 정보",
|
||||||
|
icon: "truck",
|
||||||
|
color: "blue",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_trip_start", label: "시작", format: "datetime" },
|
||||||
|
{ column: "last_trip_end", label: "종료", format: "datetime" },
|
||||||
|
{ column: "last_trip_distance", label: "거리", format: "distance" },
|
||||||
|
{ column: "last_trip_time", label: "시간", format: "duration" },
|
||||||
|
{ column: "departure", label: "출발지", format: "text" },
|
||||||
|
{ column: "arrival", label: "도착지", format: "text" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공차 정보
|
||||||
|
if (additional.last_empty_start) {
|
||||||
|
groups.push({
|
||||||
|
id: "empty",
|
||||||
|
title: "공차 정보",
|
||||||
|
icon: "package",
|
||||||
|
color: "orange",
|
||||||
|
fields: [
|
||||||
|
{ column: "last_empty_start", label: "시작", format: "datetime" },
|
||||||
|
{ column: "last_empty_end", label: "종료", format: "datetime" },
|
||||||
|
{ column: "last_empty_distance", label: "거리", format: "distance" },
|
||||||
|
{ column: "last_empty_time", label: "시간", format: "duration" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
// 데이터 로드
|
// 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -61,16 +285,46 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 요청 메서드 (기본값: GET)
|
||||||
|
const requestMethod = element.dataSource.method || "GET";
|
||||||
|
|
||||||
|
// 요청 body (POST, PUT, PATCH인 경우)
|
||||||
|
let requestBody = undefined;
|
||||||
|
if (["POST", "PUT", "PATCH"].includes(requestMethod) && element.dataSource.body) {
|
||||||
|
try {
|
||||||
|
requestBody = typeof element.dataSource.body === "string"
|
||||||
|
? JSON.parse(element.dataSource.body)
|
||||||
|
: element.dataSource.body;
|
||||||
|
} catch {
|
||||||
|
requestBody = element.dataSource.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// headers를 KeyValuePair[] 에서 객체로 변환
|
||||||
|
const headersObj: Record<string, string> = {};
|
||||||
|
if (element.dataSource.headers && Array.isArray(element.dataSource.headers)) {
|
||||||
|
element.dataSource.headers.forEach((h: any) => {
|
||||||
|
if (h.key && h.value) {
|
||||||
|
headersObj[h.key] = h.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (element.dataSource.headers && typeof element.dataSource.headers === "object") {
|
||||||
|
Object.assign(headersObj, element.dataSource.headers);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: element.dataSource.endpoint,
|
url: element.dataSource.endpoint,
|
||||||
method: "GET",
|
method: requestMethod,
|
||||||
headers: element.dataSource.headers || {},
|
headers: headersObj,
|
||||||
queryParams: Object.fromEntries(params),
|
queryParams: Object.fromEntries(params),
|
||||||
|
body: requestBody,
|
||||||
|
externalConnectionId: element.dataSource.externalConnectionId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -260,7 +514,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
paginatedRows.map((row, idx) => (
|
paginatedRows.map((row, idx) => (
|
||||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
<TableRow
|
||||||
|
key={idx}
|
||||||
|
className={`${config.stripedRows ? "" : ""} ${config.rowDetailPopup?.enabled ? "cursor-pointer transition-colors hover:bg-muted/50" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
{displayColumns
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
|
|
@ -292,7 +550,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{paginatedRows.map((row, idx) => (
|
{paginatedRows.map((row, idx) => (
|
||||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
<Card
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 transition-shadow hover:shadow-md ${config.rowDetailPopup?.enabled ? "cursor-pointer" : ""}`}
|
||||||
|
onClick={() => handleRowClick(row)}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayColumns
|
{displayColumns
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
|
|
@ -345,6 +607,49 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 행 상세 팝업 */}
|
||||||
|
<Dialog open={detailPopupOpen} onOpenChange={setDetailPopupOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[600px] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{config.rowDetailPopup?.title || "상세 정보"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detailPopupLoading
|
||||||
|
? "추가 정보를 로딩 중입니다..."
|
||||||
|
: detailPopupData
|
||||||
|
? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}`
|
||||||
|
: "선택된 항목의 상세 정보입니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailPopupLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{detailPopupData && (
|
||||||
|
<>
|
||||||
|
{/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */}
|
||||||
|
{config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0
|
||||||
|
? // 설정된 필드 그룹 렌더링
|
||||||
|
config.rowDetailPopup.fieldGroups.map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)
|
||||||
|
: // 기본 필드 그룹 렌더링
|
||||||
|
getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) =>
|
||||||
|
renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setDetailPopupOpen(false)}>닫기</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
@ -94,10 +94,10 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
||||||
if (!open) onClose();
|
if (!open) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ResizableDialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle>자재 배치 설정</ResizableDialogTitle>
|
<DialogTitle>자재 배치 설정</DialogTitle>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 자재 정보 */}
|
{/* 자재 정보 */}
|
||||||
|
|
@ -233,7 +233,7 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose} disabled={isAdding}>
|
<Button variant="outline" onClick={onClose} disabled={isAdding}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -247,8 +247,8 @@ export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: M
|
||||||
"배치"
|
"배치"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, Loader2 } from "lucide-react";
|
import { Search, Loader2 } from "lucide-react";
|
||||||
import { materialApi } from "@/lib/api/yardLayoutApi";
|
import { materialApi } from "@/lib/api/yardLayoutApi";
|
||||||
|
|
|
||||||
|
|
@ -526,7 +526,8 @@ function MaterialBox({
|
||||||
case "location-temp":
|
case "location-temp":
|
||||||
case "location-dest":
|
case "location-dest":
|
||||||
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||||
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
// 자재가 없으면 0, 있으면 해당 개수 표시 (기본값 5 제거)
|
||||||
|
const locPlateCount = placement.material_count ?? placement.quantity ?? 0;
|
||||||
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||||
const locPlateThickness = 0.15; // 각 철판 두께
|
const locPlateThickness = 0.15; // 각 철판 두께
|
||||||
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||||
|
|
@ -538,8 +539,32 @@ function MaterialBox({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
{/* 자재가 없을 때: 흰색 실선 테두리 바닥판 */}
|
||||||
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
{locPlateCount === 0 && (
|
||||||
|
<>
|
||||||
|
{/* 얇은 흰색 바닥판 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth, 0.05, boxDepth]}
|
||||||
|
position={[0, locYOffset + 0.025, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f5f5f5"
|
||||||
|
roughness={0.6}
|
||||||
|
metalness={0.1}
|
||||||
|
emissive={isSelected ? "#e5e5e5" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 흰색 실선 테두리 */}
|
||||||
|
<lineSegments position={[0, locYOffset + 0.06, 0]}>
|
||||||
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, 0.05, boxDepth)]} />
|
||||||
|
<lineBasicMaterial color="#9ca3af" linewidth={2} />
|
||||||
|
</lineSegments>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 */}
|
||||||
|
{locPlateCount > 0 && Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||||
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||||
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||||
|
|
@ -570,7 +595,7 @@ function MaterialBox({
|
||||||
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||||
{placement.name && (
|
{placement.name && (
|
||||||
<Text
|
<Text
|
||||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
position={[0, locYOffset + (locPlateCount > 0 ? locVisibleStackHeight : 0.1) + 0.3, boxDepth * 0.3]}
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||||
color="#374151"
|
color="#374151"
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
|
@ -64,14 +64,14 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
<DialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ResizableDialogTitle>새로운 3D필드 생성</ResizableDialogTitle>
|
<DialogTitle>새로운 3D필드 생성</DialogTitle>
|
||||||
<ResizableDialogDescription>필드 이름을 입력하세요</ResizableDialogDescription>
|
<DialogDescription>필드 이름을 입력하세요</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -100,7 +100,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -114,8 +114,8 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
||||||
"생성"
|
"생성"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
import { Camera, CameraOff, CheckCircle2, AlertCircle, Scan } from "lucide-react";
|
||||||
|
|
@ -179,26 +179,15 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResizableDialogContent
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
className="max-w-[95vw] sm:max-w-[600px]"
|
<DialogHeader>
|
||||||
defaultWidth={600}
|
<DialogTitle className="text-base sm:text-lg">바코드 스캔</DialogTitle>
|
||||||
defaultHeight={700}
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
minWidth={400}
|
|
||||||
minHeight={500}
|
|
||||||
maxWidth={900}
|
|
||||||
maxHeight={900}
|
|
||||||
modalId="barcode-scan"
|
|
||||||
userId={userId}
|
|
||||||
>
|
|
||||||
<ResizableDialogHeader>
|
|
||||||
<ResizableDialogTitle className="text-base sm:text-lg">바코드 스캔</ResizableDialogTitle>
|
|
||||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
|
||||||
카메라로 바코드를 스캔하세요.
|
카메라로 바코드를 스캔하세요.
|
||||||
{targetField && ` (대상 필드: ${targetField})`}
|
{targetField && ` (대상 필드: ${targetField})`}
|
||||||
모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
</DialogDescription>
|
||||||
</ResizableDialogDescription>
|
</DialogHeader>
|
||||||
</ResizableDialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 카메라 권한 요청 대기 중 */}
|
{/* 카메라 권한 요청 대기 중 */}
|
||||||
|
|
@ -337,7 +326,7 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
|
@ -376,9 +365,9 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||||
확인
|
확인
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔗 연쇄 드롭다운(Cascading Dropdown) 컴포넌트
|
||||||
|
*
|
||||||
|
* 부모 필드의 값에 따라 옵션이 동적으로 변경되는 드롭다운입니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 창고 → 위치 연쇄 드롭다운
|
||||||
|
* <CascadingDropdown
|
||||||
|
* config={{
|
||||||
|
* enabled: true,
|
||||||
|
* parentField: "warehouse_code",
|
||||||
|
* sourceTable: "warehouse_location",
|
||||||
|
* parentKeyColumn: "warehouse_id",
|
||||||
|
* valueColumn: "location_code",
|
||||||
|
* labelColumn: "location_name",
|
||||||
|
* }}
|
||||||
|
* parentValue={formData.warehouse_code}
|
||||||
|
* value={formData.location_code}
|
||||||
|
* onChange={(value) => setFormData({ ...formData, location_code: value })}
|
||||||
|
* placeholder="위치 선택"
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useCascadingDropdown, CascadingOption } from "@/hooks/useCascadingDropdown";
|
||||||
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface CascadingDropdownProps {
|
||||||
|
/** 연쇄 드롭다운 설정 */
|
||||||
|
config: CascadingDropdownConfig;
|
||||||
|
|
||||||
|
/** 부모 필드의 현재 값 */
|
||||||
|
parentValue?: string | number | null;
|
||||||
|
|
||||||
|
/** 현재 선택된 값 */
|
||||||
|
value?: string;
|
||||||
|
|
||||||
|
/** 값 변경 핸들러 */
|
||||||
|
onChange?: (value: string, option?: CascadingOption) => void;
|
||||||
|
|
||||||
|
/** 플레이스홀더 */
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
/** 비활성화 여부 */
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/** 읽기 전용 여부 */
|
||||||
|
readOnly?: boolean;
|
||||||
|
|
||||||
|
/** 필수 입력 여부 */
|
||||||
|
required?: boolean;
|
||||||
|
|
||||||
|
/** 추가 클래스명 */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** 추가 스타일 */
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
/** 검색 가능 여부 */
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
required = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
searchable = false,
|
||||||
|
}: CascadingDropdownProps) {
|
||||||
|
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
options,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
getLabelByValue,
|
||||||
|
} = useCascadingDropdown({
|
||||||
|
config,
|
||||||
|
parentValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 부모 값 변경 시 자동 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.clearOnParentChange !== false) {
|
||||||
|
if (prevParentValueRef.current !== undefined &&
|
||||||
|
prevParentValueRef.current !== parentValue &&
|
||||||
|
value) {
|
||||||
|
// 부모 값이 변경되면 현재 값 초기화
|
||||||
|
onChange?.("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevParentValueRef.current = parentValue;
|
||||||
|
}, [parentValue, config.clearOnParentChange, value, onChange]);
|
||||||
|
|
||||||
|
// 부모 값이 없을 때 메시지
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (!parentValue) {
|
||||||
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return config.loadingMessage || "로딩 중...";
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
||||||
|
}
|
||||||
|
return placeholder || "선택하세요";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경 핸들러
|
||||||
|
const handleValueChange = (newValue: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
|
||||||
|
const selectedOption = options.find((opt) => opt.value === newValue);
|
||||||
|
onChange?.(newValue, selectedOption);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 비활성화 상태 계산
|
||||||
|
const isDisabled = disabled || readOnly || !parentValue || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)} style={style}>
|
||||||
|
<Select
|
||||||
|
value={value || ""}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
!parentValue && "text-muted-foreground",
|
||||||
|
error && "border-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{config.loadingMessage || "로딩 중..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder={getPlaceholder()} />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||||
|
{!parentValue
|
||||||
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||||
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-destructive mt-1 text-xs">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CascadingDropdown;
|
||||||
|
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
ResizableDialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -385,27 +385,27 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<ResizableDialogContent
|
<DialogContent
|
||||||
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
|
||||||
defaultWidth={1000}
|
style={{
|
||||||
defaultHeight={700}
|
width: "1000px",
|
||||||
minWidth={700}
|
height: "700px",
|
||||||
minHeight={500}
|
minWidth: "700px",
|
||||||
maxWidth={1400}
|
minHeight: "500px",
|
||||||
maxHeight={900}
|
maxWidth: "1400px",
|
||||||
modalId={`excel-upload-${tableName}`}
|
maxHeight: "900px",
|
||||||
userId={userId || "guest"}
|
}}
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader>
|
<DialogHeader>
|
||||||
<ResizableDialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<FileSpreadsheet className="h-5 w-5" />
|
<FileSpreadsheet className="h-5 w-5" />
|
||||||
엑셀 데이터 업로드
|
엑셀 데이터 업로드
|
||||||
</ResizableDialogTitle>
|
</DialogTitle>
|
||||||
<ResizableDialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* 스텝 인디케이터 */}
|
{/* 스텝 인디케이터 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -863,7 +863,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
|
||||||
|
|
@ -889,8 +889,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
{isUploading ? "업로드 중..." : "다음"}
|
{isUploading ? "업로드 중..." : "다음"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ResizableDialogFooter>
|
</DialogFooter>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ResizableDialog,
|
Dialog,
|
||||||
ResizableDialogContent,
|
DialogContent,
|
||||||
ResizableDialogHeader,
|
DialogHeader,
|
||||||
ResizableDialogTitle,
|
DialogTitle,
|
||||||
ResizableDialogDescription,
|
DialogDescription,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
|
|
@ -514,16 +514,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + padding
|
||||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
|
||||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||||
|
const dialogGap = 16; // DialogContent gap-4
|
||||||
|
const extraPadding = 24; // 추가 여백 (안전 마진)
|
||||||
|
|
||||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + extraPadding;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden p-0",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
|
||||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||||
maxWidth: "98vw",
|
maxWidth: "98vw",
|
||||||
maxHeight: "95vh",
|
maxHeight: "95vh",
|
||||||
|
|
@ -593,36 +595,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||||
<ResizableDialogContent
|
<DialogContent
|
||||||
className={`${modalStyle.className} ${className || ""}`}
|
className={`${modalStyle.className} ${className || ""} max-w-none`}
|
||||||
{...(modalStyle.style && { style: modalStyle.style })} // undefined일 때는 prop 자체를 전달하지 않음
|
{...(modalStyle.style && { style: modalStyle.style })}
|
||||||
defaultWidth={600}
|
|
||||||
defaultHeight={800}
|
|
||||||
minWidth={500}
|
|
||||||
minHeight={400}
|
|
||||||
maxWidth={1600}
|
|
||||||
maxHeight={1200}
|
|
||||||
modalId={persistedModalId}
|
|
||||||
userId={userId || "guest"}
|
|
||||||
>
|
>
|
||||||
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ResizableDialogTitle className="text-base">{modalState.title}</ResizableDialogTitle>
|
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||||
{modalState.description && !loading && (
|
{modalState.description && !loading && (
|
||||||
<ResizableDialogDescription className="text-muted-foreground text-xs">
|
<DialogDescription className="text-muted-foreground text-xs">
|
||||||
{modalState.description}
|
{modalState.description}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<ResizableDialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
{loading ? "화면을 불러오는 중입니다..." : ""}
|
{loading ? "화면을 불러오는 중입니다..." : ""}
|
||||||
</ResizableDialogDescription>
|
</DialogDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -728,8 +722,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizableDialogContent>
|
</DialogContent>
|
||||||
</ResizableDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue