Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
3dc67dd60a
|
|
@ -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` - 스티키 헤더 테이블
|
||||
|
|
@ -632,6 +632,9 @@ export class DashboardController {
|
|||
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||
};
|
||||
|
||||
// 연결 정보 (응답에 포함용)
|
||||
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||
|
||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||
if (externalConnectionId) {
|
||||
try {
|
||||
|
|
@ -652,6 +655,11 @@ export class DashboardController {
|
|||
if (connectionResult.success && connectionResult.data) {
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 연결 정보 저장 (응답에 포함)
|
||||
connectionInfo = {
|
||||
saveToHistory: connection.save_to_history === "Y",
|
||||
};
|
||||
|
||||
// 인증 헤더 생성 (DB 토큰 등)
|
||||
const authHeaders =
|
||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||
|
|
@ -709,9 +717,9 @@ export class DashboardController {
|
|||
}
|
||||
|
||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
||||
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
||||
if (isKmaApi) {
|
||||
requestConfig.responseType = 'arraybuffer';
|
||||
requestConfig.responseType = "arraybuffer";
|
||||
}
|
||||
|
||||
const response = await axios(requestConfig);
|
||||
|
|
@ -727,18 +735,22 @@ export class DashboardController {
|
|||
|
||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||
const iconv = require('iconv-lite');
|
||||
const iconv = require("iconv-lite");
|
||||
const buffer = Buffer.from(data);
|
||||
const utf8Text = buffer.toString('utf-8');
|
||||
const utf8Text = buffer.toString("utf-8");
|
||||
|
||||
// UTF-8로 정상 디코딩되었는지 확인
|
||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
||||
if (
|
||||
utf8Text.includes("특보") ||
|
||||
utf8Text.includes("경보") ||
|
||||
utf8Text.includes("주의보") ||
|
||||
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
|
||||
) {
|
||||
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||
} else {
|
||||
// EUC-KR로 디코딩
|
||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
||||
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||
}
|
||||
}
|
||||
// 텍스트 응답인 경우 포맷팅
|
||||
|
|
@ -749,6 +761,7 @@ export class DashboardController {
|
|||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||
});
|
||||
} catch (error: any) {
|
||||
const status = error.response?.status || 500;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// 배치관리 전용 컨트롤러 (기존 소스와 완전 분리)
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import { Response } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
BatchManagementService,
|
||||
|
|
@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService";
|
|||
import { BatchSchedulerService } from "../services/batchSchedulerService";
|
||||
import { BatchExternalDbService } from "../services/batchExternalDbService";
|
||||
import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchManagementController {
|
||||
/**
|
||||
|
|
@ -422,6 +423,8 @@ export class BatchManagementController {
|
|||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
authServiceName, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath, // 데이터 배열 경로 (예: response, data.items)
|
||||
} = req.body;
|
||||
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
|
|
@ -432,15 +435,47 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
|
||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||
if ((!method || method === "GET") && !apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||
});
|
||||
// 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용
|
||||
let finalApiKey = apiKey || "";
|
||||
if (authServiceName) {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// DB에서 토큰 조회 (멀티테넌시: company_code 필터링)
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 토큰 조회 가능
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 토큰만 조회
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [authServiceName, companyCode];
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
const tokenResult = await query<{ access_token: string }>(
|
||||
tokenQuery,
|
||||
tokenParams
|
||||
);
|
||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||
finalApiKey = tokenResult[0].access_token;
|
||||
console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰이 없어도 공개 API 호출 가능 (토큰 검증 제거)
|
||||
|
||||
console.log("REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
|
|
@ -449,6 +484,8 @@ export class BatchManagementController {
|
|||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
authServiceName: authServiceName || "직접 입력",
|
||||
dataArrayPath: dataArrayPath || "전체 응답",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
|
|
@ -456,7 +493,7 @@ export class BatchManagementController {
|
|||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey || "",
|
||||
apiKey: finalApiKey,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
|
@ -511,8 +548,50 @@ export class BatchManagementController {
|
|||
result.rows && result.rows.length > 0 ? result.rows[0] : "no data",
|
||||
});
|
||||
|
||||
const data = result.rows.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(`[previewRestApiData] 슬라이스된 데이터:`, data);
|
||||
// 데이터 배열 추출 헬퍼 함수
|
||||
const getValueByPath = (obj: any, path: string): any => {
|
||||
if (!path) return obj;
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// dataArrayPath가 있으면 해당 경로에서 배열 추출
|
||||
let extractedData: any[] = [];
|
||||
if (dataArrayPath) {
|
||||
// result.rows가 단일 객체일 수 있음 (API 응답 전체)
|
||||
const rawData = result.rows.length === 1 ? result.rows[0] : result.rows;
|
||||
const arrayData = getValueByPath(rawData, dataArrayPath);
|
||||
|
||||
if (Array.isArray(arrayData)) {
|
||||
extractedData = arrayData;
|
||||
console.log(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출`
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`,
|
||||
typeof arrayData
|
||||
);
|
||||
// 배열이 아니면 단일 객체로 처리
|
||||
if (arrayData) {
|
||||
extractedData = [arrayData];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// dataArrayPath가 없으면 기존 로직 사용
|
||||
extractedData = result.rows;
|
||||
}
|
||||
|
||||
const data = extractedData.slice(0, 5); // 최대 5개 샘플만
|
||||
console.log(
|
||||
`[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`,
|
||||
data
|
||||
);
|
||||
|
||||
if (data.length > 0) {
|
||||
// 첫 번째 객체에서 필드명 추출
|
||||
|
|
@ -524,9 +603,9 @@ export class BatchManagementController {
|
|||
data: {
|
||||
fields: fields,
|
||||
samples: data,
|
||||
totalCount: result.rowCount || data.length,
|
||||
totalCount: extractedData.length,
|
||||
},
|
||||
message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`,
|
||||
message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`,
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
|
|
@ -554,8 +633,17 @@ export class BatchManagementController {
|
|||
*/
|
||||
static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, batchType, cronSchedule, description, apiMappings } =
|
||||
req.body;
|
||||
const {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
} = req.body;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
|
|
@ -576,6 +664,10 @@ export class BatchManagementController {
|
|||
cronSchedule,
|
||||
description,
|
||||
apiMappings,
|
||||
authServiceName,
|
||||
dataArrayPath,
|
||||
saveMode,
|
||||
conflictKey,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
|
|
@ -589,6 +681,10 @@ export class BatchManagementController {
|
|||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
authServiceName: authServiceName || undefined,
|
||||
dataArrayPath: dataArrayPath || undefined,
|
||||
saveMode: saveMode || "INSERT",
|
||||
conflictKey: conflictKey || undefined,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
|
|
@ -625,4 +721,51 @@ export class BatchManagementController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 토큰 서비스명 목록 조회
|
||||
*/
|
||||
static async getAuthServiceNames(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
let queryText: string;
|
||||
let queryParams: any[] = [];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 서비스 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
ORDER BY service_name`;
|
||||
} else {
|
||||
// 일반 회사: 자신의 회사 서비스만 조회
|
||||
queryText = `SELECT DISTINCT service_name
|
||||
FROM auth_tokens
|
||||
WHERE service_name IS NOT NULL
|
||||
AND company_code = $1
|
||||
ORDER BY service_name`;
|
||||
queryParams = [companyCode];
|
||||
}
|
||||
|
||||
const result = await query<{ service_name: string }>(
|
||||
queryText,
|
||||
queryParams
|
||||
);
|
||||
|
||||
const serviceNames = result.map((row) => row.service_name);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: serviceNames,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("인증 서비스 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "인증 서비스 목록 조회 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ export const saveLocationHistory = async (
|
|||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { companyCode, userId: loginUserId } = req.user as any;
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
|
|
@ -508,10 +508,17 @@ export const saveLocationHistory = async (
|
|||
destinationName,
|
||||
recordedAt,
|
||||
vehicleId,
|
||||
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||
} = req.body;
|
||||
|
||||
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||
// 없으면 로그인한 사용자의 userId 사용
|
||||
const userId = requestUserId || loginUserId;
|
||||
|
||||
console.log("📍 [saveLocationHistory] 요청:", {
|
||||
userId,
|
||||
requestUserId,
|
||||
loginUserId,
|
||||
companyCode,
|
||||
latitude,
|
||||
longitude,
|
||||
|
|
|
|||
|
|
@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr
|
|||
*/
|
||||
router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch);
|
||||
|
||||
/**
|
||||
* GET /api/batch-management/auth-services
|
||||
* 인증 토큰 서비스명 목록 조회
|
||||
*/
|
||||
router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron";
|
|||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { query } from "../database/db";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, ScheduledTask> = new Map();
|
||||
|
|
@ -214,9 +215,16 @@ export class BatchSchedulerService {
|
|||
}
|
||||
|
||||
// 테이블별로 매핑을 그룹화
|
||||
// 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리
|
||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||
const fixedMappingsGlobal: typeof config.batch_mappings = [];
|
||||
|
||||
for (const mapping of config.batch_mappings) {
|
||||
// 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음)
|
||||
if (mapping.mapping_type === "fixed") {
|
||||
fixedMappingsGlobal.push(mapping);
|
||||
continue;
|
||||
}
|
||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
||||
if (!tableGroups.has(key)) {
|
||||
tableGroups.set(key, []);
|
||||
|
|
@ -224,6 +232,14 @@ export class BatchSchedulerService {
|
|||
tableGroups.get(key)!.push(mapping);
|
||||
}
|
||||
|
||||
// 고정값 매핑만 있고 일반 매핑이 없는 경우 처리
|
||||
if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) {
|
||||
logger.warn(
|
||||
`일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.`
|
||||
);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
// 각 테이블 그룹별로 처리
|
||||
for (const [tableKey, mappings] of tableGroups) {
|
||||
try {
|
||||
|
|
@ -244,10 +260,46 @@ export class BatchSchedulerService {
|
|||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 (멀티테넌시 적용)
|
||||
let apiKey = firstMapping.from_api_key || "";
|
||||
if (config.auth_service_name) {
|
||||
let tokenQuery: string;
|
||||
let tokenParams: any[];
|
||||
|
||||
if (config.company_code === "*") {
|
||||
// 최고 관리자 배치: 모든 회사 토큰 조회 가능
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [config.auth_service_name];
|
||||
} else {
|
||||
// 일반 회사 배치: 자신의 회사 토큰만 조회
|
||||
tokenQuery = `SELECT access_token FROM auth_tokens
|
||||
WHERE service_name = $1 AND company_code = $2
|
||||
ORDER BY created_date DESC LIMIT 1`;
|
||||
tokenParams = [config.auth_service_name, config.company_code];
|
||||
}
|
||||
|
||||
const tokenResult = await query<{ access_token: string }>(
|
||||
tokenQuery,
|
||||
tokenParams
|
||||
);
|
||||
if (tokenResult.length > 0 && tokenResult[0].access_token) {
|
||||
apiKey = tokenResult[0].access_token;
|
||||
logger.info(
|
||||
`auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
apiKey,
|
||||
firstMapping.from_table_name,
|
||||
(firstMapping.from_api_method as
|
||||
| "GET"
|
||||
|
|
@ -266,7 +318,36 @@ export class BatchSchedulerService {
|
|||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
// 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출
|
||||
if (config.data_array_path) {
|
||||
const extractArrayByPath = (obj: any, path: string): any[] => {
|
||||
if (!path) return Array.isArray(obj) ? obj : [obj];
|
||||
const keys = path.split(".");
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current === null || current === undefined) return [];
|
||||
current = current[key];
|
||||
}
|
||||
return Array.isArray(current)
|
||||
? current
|
||||
: current
|
||||
? [current]
|
||||
: [];
|
||||
};
|
||||
|
||||
// apiResult.data가 단일 객체인 경우 (API 응답 전체)
|
||||
const rawData =
|
||||
Array.isArray(apiResult.data) && apiResult.data.length === 1
|
||||
? apiResult.data[0]
|
||||
: apiResult.data;
|
||||
|
||||
fromData = extractArrayByPath(rawData, config.data_array_path);
|
||||
logger.info(
|
||||
`데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출`
|
||||
);
|
||||
} else {
|
||||
fromData = apiResult.data;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`);
|
||||
}
|
||||
|
|
@ -298,6 +379,11 @@ export class BatchSchedulerService {
|
|||
const mappedData = fromData.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
// 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음
|
||||
if (mapping.mapping_type === "fixed") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// DB → REST API 배치인지 확인
|
||||
if (
|
||||
firstMapping.to_connection_type === "restapi" &&
|
||||
|
|
@ -315,6 +401,13 @@ export class BatchSchedulerService {
|
|||
}
|
||||
}
|
||||
|
||||
// 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용)
|
||||
for (const fixedMapping of fixedMappingsGlobal) {
|
||||
// from_column_name에 고정값이 저장되어 있음
|
||||
mappedRow[fixedMapping.to_column_name] =
|
||||
fixedMapping.from_column_name;
|
||||
}
|
||||
|
||||
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||
// - 배치 설정에 company_code가 있고
|
||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||
|
|
@ -384,12 +477,14 @@ export class BatchSchedulerService {
|
|||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
// DB에 데이터 삽입 (save_mode, conflict_key 지원)
|
||||
insertResult = await BatchService.insertDataToTable(
|
||||
firstMapping.to_table_name,
|
||||
mappedData,
|
||||
firstMapping.to_connection_type as "internal" | "external",
|
||||
firstMapping.to_connection_id || undefined
|
||||
firstMapping.to_connection_id || undefined,
|
||||
(config.save_mode as "INSERT" | "UPSERT") || "INSERT",
|
||||
config.conflict_key || undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -176,8 +176,8 @@ export class BatchService {
|
|||
// 배치 설정 생성
|
||||
const batchConfigResult = await client.query(
|
||||
`INSERT INTO batch_configs
|
||||
(batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batchName,
|
||||
|
|
@ -185,6 +185,10 @@ export class BatchService {
|
|||
data.cronSchedule,
|
||||
data.isActive || "Y",
|
||||
data.companyCode,
|
||||
data.saveMode || "INSERT",
|
||||
data.conflictKey || null,
|
||||
data.authServiceName || null,
|
||||
data.dataArrayPath || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -201,8 +205,8 @@ export class BatchService {
|
|||
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
||||
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
||||
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, $26, NOW())
|
||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, 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, $26, $27, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
batchConfig.id,
|
||||
|
|
@ -230,6 +234,7 @@ export class BatchService {
|
|||
mapping.to_api_method,
|
||||
mapping.to_api_body,
|
||||
mapping.mapping_order || index + 1,
|
||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -311,6 +316,22 @@ export class BatchService {
|
|||
updateFields.push(`is_active = $${paramIndex++}`);
|
||||
updateValues.push(data.isActive);
|
||||
}
|
||||
if (data.saveMode !== undefined) {
|
||||
updateFields.push(`save_mode = $${paramIndex++}`);
|
||||
updateValues.push(data.saveMode);
|
||||
}
|
||||
if (data.conflictKey !== undefined) {
|
||||
updateFields.push(`conflict_key = $${paramIndex++}`);
|
||||
updateValues.push(data.conflictKey || null);
|
||||
}
|
||||
if (data.authServiceName !== undefined) {
|
||||
updateFields.push(`auth_service_name = $${paramIndex++}`);
|
||||
updateValues.push(data.authServiceName || null);
|
||||
}
|
||||
if (data.dataArrayPath !== undefined) {
|
||||
updateFields.push(`data_array_path = $${paramIndex++}`);
|
||||
updateValues.push(data.dataArrayPath || null);
|
||||
}
|
||||
|
||||
// 배치 설정 업데이트
|
||||
const batchConfigResult = await client.query(
|
||||
|
|
@ -339,8 +360,8 @@ export class BatchService {
|
|||
from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type,
|
||||
from_api_param_name, from_api_param_value, from_api_param_source, from_api_body,
|
||||
to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type,
|
||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, 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, $26, NOW())
|
||||
to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, 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, $26, $27, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
|
|
@ -368,6 +389,7 @@ export class BatchService {
|
|||
mapping.to_api_method,
|
||||
mapping.to_api_body,
|
||||
mapping.mapping_order || index + 1,
|
||||
mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
|
@ -554,9 +576,7 @@ export class BatchService {
|
|||
try {
|
||||
if (connectionType === "internal") {
|
||||
// 내부 DB 데이터 조회
|
||||
const data = await query<any>(
|
||||
`SELECT * FROM ${tableName} LIMIT 10`
|
||||
);
|
||||
const data = await query<any>(`SELECT * FROM ${tableName} LIMIT 10`);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
|
|
@ -729,19 +749,27 @@ export class BatchService {
|
|||
|
||||
/**
|
||||
* 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분)
|
||||
* @param tableName 테이블명
|
||||
* @param data 삽입할 데이터 배열
|
||||
* @param connectionType 연결 타입 (internal/external)
|
||||
* @param connectionId 외부 연결 ID
|
||||
* @param saveMode 저장 모드 (INSERT/UPSERT)
|
||||
* @param conflictKey UPSERT 시 충돌 기준 컬럼명
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
tableName: string,
|
||||
data: any[],
|
||||
connectionType: "internal" | "external" = "internal",
|
||||
connectionId?: number
|
||||
connectionId?: number,
|
||||
saveMode: "INSERT" | "UPSERT" = "INSERT",
|
||||
conflictKey?: string
|
||||
): Promise<{
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
}> {
|
||||
try {
|
||||
console.log(
|
||||
`[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드`
|
||||
`[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}`
|
||||
);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -753,24 +781,54 @@ export class BatchService {
|
|||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리)
|
||||
// 각 레코드를 개별적으로 삽입
|
||||
for (const record of data) {
|
||||
try {
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record);
|
||||
const placeholders = values
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let queryStr: string;
|
||||
|
||||
if (saveMode === "UPSERT" && conflictKey) {
|
||||
// UPSERT 모드: ON CONFLICT DO UPDATE
|
||||
// 충돌 키를 제외한 컬럼들만 UPDATE
|
||||
const updateColumns = columns.filter(
|
||||
(col) => col !== conflictKey
|
||||
);
|
||||
|
||||
// 업데이트할 컬럼이 없으면 DO NOTHING 사용
|
||||
if (updateColumns.length === 0) {
|
||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${placeholders})
|
||||
ON CONFLICT (${conflictKey})
|
||||
DO NOTHING`;
|
||||
} else {
|
||||
const updateSet = updateColumns
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(", ");
|
||||
|
||||
const queryStr = `INSERT INTO ${tableName} (${columns.join(
|
||||
", "
|
||||
)}) VALUES (${placeholders})`;
|
||||
// updated_date 컬럼이 있으면 현재 시간으로 업데이트
|
||||
const hasUpdatedDate = columns.includes("updated_date");
|
||||
const finalUpdateSet = hasUpdatedDate
|
||||
? `${updateSet}, updated_date = NOW()`
|
||||
: updateSet;
|
||||
|
||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")})
|
||||
VALUES (${placeholders})
|
||||
ON CONFLICT (${conflictKey})
|
||||
DO UPDATE SET ${finalUpdateSet}`;
|
||||
}
|
||||
} else {
|
||||
// INSERT 모드: 기존 방식
|
||||
queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
}
|
||||
|
||||
await query(queryStr, values);
|
||||
successCount++;
|
||||
} catch (insertError) {
|
||||
console.error(
|
||||
`내부 DB 데이터 삽입 실패 (${tableName}):`,
|
||||
`내부 DB 데이터 ${saveMode} 실패 (${tableName}):`,
|
||||
insertError
|
||||
);
|
||||
failedCount++;
|
||||
|
|
@ -779,7 +837,13 @@ export class BatchService {
|
|||
|
||||
return { successCount, failedCount };
|
||||
} else if (connectionType === "external" && connectionId) {
|
||||
// 외부 DB에 데이터 삽입
|
||||
// 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원)
|
||||
if (saveMode === "UPSERT") {
|
||||
console.warn(
|
||||
`[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await BatchExternalDbService.insertDataToTable(
|
||||
connectionId,
|
||||
tableName,
|
||||
|
|
@ -799,7 +863,7 @@ export class BatchService {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`데이터 삽입 오류 (${tableName}):`, error);
|
||||
console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error);
|
||||
return { successCount: 0, failedCount: data ? data.length : 0 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService {
|
|||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
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, $16)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService {
|
|||
data.company_code || "*",
|
||||
data.is_active || "Y",
|
||||
data.created_by || "system",
|
||||
data.save_to_history || "N",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
|
|
@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService {
|
|||
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) {
|
||||
updateFields.push(`updated_by = $${paramIndex}`);
|
||||
params.push(data.updated_by);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface TableInfo {
|
|||
|
||||
// 연결 정보 타입
|
||||
export interface ConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
type: "internal" | "external";
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
|
|
@ -52,27 +52,27 @@ export interface BatchMapping {
|
|||
id?: number;
|
||||
batch_config_id?: number;
|
||||
company_code?: string;
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_type: "internal" | "external" | "restapi";
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
from_api_param_type?: 'url' | 'query';
|
||||
from_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
from_api_param_type?: "url" | "query";
|
||||
from_api_param_name?: string;
|
||||
from_api_param_value?: string;
|
||||
from_api_param_source?: 'static' | 'dynamic';
|
||||
from_api_param_source?: "static" | "dynamic";
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_type: "internal" | "external" | "restapi";
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
to_api_url?: string;
|
||||
to_api_key?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
to_api_body?: string;
|
||||
mapping_order?: number;
|
||||
created_by?: string;
|
||||
|
|
@ -85,8 +85,12 @@ export interface BatchConfig {
|
|||
batch_name: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active: 'Y' | 'N';
|
||||
is_active: "Y" | "N";
|
||||
company_code?: string;
|
||||
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
|
||||
created_by?: string;
|
||||
created_date?: Date;
|
||||
updated_by?: string;
|
||||
|
|
@ -95,7 +99,7 @@ export interface BatchConfig {
|
|||
}
|
||||
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
type: "internal" | "external";
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
|
|
@ -109,38 +113,43 @@ export interface BatchColumnInfo {
|
|||
}
|
||||
|
||||
export interface BatchMappingRequest {
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_type: "internal" | "external" | "restapi" | "fixed";
|
||||
from_connection_id?: number;
|
||||
from_table_name: string;
|
||||
from_column_name: string;
|
||||
from_column_type?: string;
|
||||
from_api_url?: string;
|
||||
from_api_key?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
||||
from_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
from_api_param_type?: "url" | "query"; // API 파라미터 타입
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입
|
||||
// REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_type: "internal" | "external" | "restapi";
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
to_column_name: string;
|
||||
to_column_type?: string;
|
||||
to_api_url?: string;
|
||||
to_api_key?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
to_api_method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
mapping_order?: number;
|
||||
mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값)
|
||||
}
|
||||
|
||||
export interface CreateBatchConfigRequest {
|
||||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
isActive: 'Y' | 'N';
|
||||
isActive: "Y" | "N";
|
||||
companyCode: string;
|
||||
saveMode?: "INSERT" | "UPSERT";
|
||||
conflictKey?: string;
|
||||
authServiceName?: string;
|
||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +157,11 @@ export interface UpdateBatchConfigRequest {
|
|||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
isActive?: 'Y' | 'N';
|
||||
isActive?: "Y" | "N";
|
||||
saveMode?: "INSERT" | "UPSERT";
|
||||
conflictKey?: string;
|
||||
authServiceName?: string;
|
||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||
mappings?: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export interface ExternalRestApiConnection {
|
|||
retry_delay?: number;
|
||||
company_code: string;
|
||||
is_active: string;
|
||||
|
||||
// 위치 이력 저장 설정 (지도 위젯용)
|
||||
save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
HOST: 0.0.0.0
|
||||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [retryDelay, setRetryDelay] = useState(1000);
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정
|
||||
|
||||
// UI 상태
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
|
@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setRetryCount(connection.retry_count || 0);
|
||||
setRetryDelay(connection.retry_delay || 1000);
|
||||
setIsActive(connection.is_active === "Y");
|
||||
setSaveToHistory(connection.save_to_history === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
|
|
@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setRetryCount(0);
|
||||
setRetryDelay(1000);
|
||||
setIsActive(true);
|
||||
setSaveToHistory(false);
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
|
|
@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
retry_delay: retryDelay,
|
||||
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
||||
is_active: isActive ? "Y" : "N",
|
||||
save_to_history: saveToHistory ? "Y" : "N",
|
||||
};
|
||||
|
||||
console.log("저장하려는 데이터:", {
|
||||
|
|
@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
활성 상태
|
||||
</Label>
|
||||
</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>
|
||||
|
||||
{/* 헤더 관리 */}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
|
|
@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
</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 테스트 성공 후에만 표시) */}
|
||||
{testResult?.success && availableColumns.length > 0 && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ export interface ChartDataSource {
|
|||
label: string; // 표시할 한글명 (예: 차량 번호)
|
||||
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
||||
}[];
|
||||
|
||||
// REST API 위치 데이터 저장 설정 (MapTestWidgetV2용)
|
||||
saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
|
|
|
|||
|
|
@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||
});
|
||||
|
||||
// Location 객체들의 자재 개수 로드
|
||||
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||
const dbConnectionId = layout.external_db_connection_id;
|
||||
const hierarchyConfigParsed =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" ||
|
||||
|
|
@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
obj.type === "location-dest") &&
|
||||
obj.locaKey,
|
||||
);
|
||||
if (locationObjects.length > 0) {
|
||||
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||
setTimeout(() => {
|
||||
loadMaterialCountsForLocations(locaKeys);
|
||||
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
|
||||
if (!selectedDbConnection || locaKeys.length === 0) return;
|
||||
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
||||
const connectionId = dbConnectionId || selectedDbConnection;
|
||||
const tableName = materialTableName || selectedTables.material;
|
||||
if (!connectionId || locaKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
||||
console.log("📊 자재 개수 API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
|
|
@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
) {
|
||||
return obj;
|
||||
}
|
||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
||||
const materialCount = response.data?.find(
|
||||
(mc: any) =>
|
||||
mc.LOCAKEY === obj.locaKey ||
|
||||
mc.location_key === obj.locaKey ||
|
||||
mc.locakey === obj.locaKey
|
||||
);
|
||||
if (materialCount) {
|
||||
// count 또는 material_count 필드 사용
|
||||
const count = materialCount.count || materialCount.material_count || 0;
|
||||
const maxLayer = materialCount.max_layer || count;
|
||||
console.log(`📊 ${obj.locaKey}: 자재 ${count}개`);
|
||||
return {
|
||||
...obj,
|
||||
materialCount: materialCount.material_count,
|
||||
materialCount: Number(count),
|
||||
materialPreview: {
|
||||
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
|
||||
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
|
||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||
setLayoutName(layout.layout_name || layout.layoutName);
|
||||
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
|
||||
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
||||
setExternalDbConnectionId(dbConnectionId);
|
||||
|
||||
// hierarchy_config 저장
|
||||
let hierarchyConfigData: any = null;
|
||||
if (layout.hierarchy_config) {
|
||||
const config =
|
||||
hierarchyConfigData =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(config);
|
||||
setHierarchyConfig(hierarchyConfigData);
|
||||
}
|
||||
|
||||
// 객체 데이터 변환
|
||||
|
|
@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
});
|
||||
|
||||
setPlacedObjects(loadedObjects);
|
||||
|
||||
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
||||
if (dbConnectionId && hierarchyConfigData?.material) {
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey
|
||||
);
|
||||
|
||||
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
||||
const materialCountPromises = locationObjects.map(async (obj) => {
|
||||
try {
|
||||
const matResponse = await getMaterials(dbConnectionId, {
|
||||
tableName: hierarchyConfigData.material.tableName,
|
||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||
locaKey: obj.locaKey!,
|
||||
});
|
||||
if (matResponse.success && matResponse.data) {
|
||||
return { id: obj.id, count: matResponse.data.length };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
|
||||
}
|
||||
return { id: obj.id, count: 0 };
|
||||
});
|
||||
|
||||
const materialCounts = await Promise.all(materialCountPromises);
|
||||
|
||||
// materialCount 업데이트
|
||||
setPlacedObjects((prev) =>
|
||||
prev.map((obj) => {
|
||||
const countData = materialCounts.find((m) => m.id === obj.id);
|
||||
if (countData && countData.count > 0) {
|
||||
return { ...obj, materialCount: countData.count };
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
||||
import { OrbitControls, Box, Text } from "@react-three/drei";
|
||||
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
|
|
@ -525,68 +525,77 @@ function MaterialBox({
|
|||
case "location-bed":
|
||||
case "location-temp":
|
||||
case "location-dest":
|
||||
// 베드 타입 Location: 초록색 상자
|
||||
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
||||
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||
const locPlateThickness = 0.15; // 각 철판 두께
|
||||
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||
const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap);
|
||||
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||
const locYOffset = -placement.position_y;
|
||||
const locPlateBaseY = locYOffset + locPlateThickness / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.5}
|
||||
metalness={0.3}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||
|
||||
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
||||
{placement.material_count !== undefined &&
|
||||
placement.material_count > 0 &&
|
||||
placement.material_preview_height && (
|
||||
return (
|
||||
<Box
|
||||
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
||||
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
||||
key={`loc-plate-${idx}`}
|
||||
args={[boxWidth, locPlateThickness, boxDepth]}
|
||||
position={[xOffset, yPos, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#ef4444"
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? "#ef4444" : "#000000"}
|
||||
color="#6b7280" // 회색 (고정)
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||
transparent
|
||||
opacity={0.7}
|
||||
/>
|
||||
{/* 각 철판 외곽선 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, locPlateThickness, boxDepth)]} />
|
||||
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Location 이름 */}
|
||||
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#ffffff"
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||
color="#374151"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재 개수 */}
|
||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
|
||||
{locPlateCount > 0 && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||
color="#fbbf24"
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#1f2937"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{`자재: ${placement.material_count}개`}
|
||||
{`${locPlateCount}장`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -886,83 +895,79 @@ function MaterialBox({
|
|||
|
||||
case "plate-stack":
|
||||
default:
|
||||
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
|
||||
// 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||
const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장)
|
||||
const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링
|
||||
const plateThickness = 0.15; // 각 철판 두께
|
||||
const plateGap = 0.03; // 철판 사이 미세한 간격
|
||||
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||
const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap);
|
||||
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||
const yOffset = -placement.position_y;
|
||||
const plateBaseY = yOffset + plateThickness / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
||||
<group position={[0, palletYOffset, 0]}>
|
||||
{/* 상단 가로 판자들 (5개) */}
|
||||
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
||||
<Box
|
||||
key={`top-${idx}`}
|
||||
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
||||
position={[0, palletHeight * 0.35, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry
|
||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
|
||||
/>
|
||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
|
||||
const yPos = plateBaseY + idx * (plateThickness + plateGap);
|
||||
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
|
||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||
|
||||
{/* 중간 세로 받침대 (3개) */}
|
||||
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
||||
return (
|
||||
<Box
|
||||
key={`middle-${idx}`}
|
||||
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
||||
position={[xOffset, 0, 0]}
|
||||
key={`plate-${idx}`}
|
||||
args={[boxWidth, plateThickness, boxDepth]}
|
||||
position={[xOffset, yPos, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry
|
||||
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
|
||||
/>
|
||||
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 하단 가로 판자들 (3개) */}
|
||||
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
||||
<Box
|
||||
key={`bottom-${idx}`}
|
||||
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
||||
position={[0, -palletHeight * 0.35, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
||||
<lineSegments>
|
||||
<edgesGeometry
|
||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
|
||||
/>
|
||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
))}
|
||||
</group>
|
||||
|
||||
{/* 메인 박스 */}
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
|
||||
{/* 메인 재질 - 골판지 느낌 */}
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
||||
transparent
|
||||
emissive={isSelected ? "#ffffff" : "#000000"}
|
||||
emissiveIntensity={isSelected ? 0.2 : 0}
|
||||
wireframe={!isConfigured}
|
||||
roughness={0.95}
|
||||
metalness={0.05}
|
||||
color="#6b7280" // 회색 (고정)
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||
/>
|
||||
|
||||
{/* 외곽선 - 더 진하게 */}
|
||||
{/* 각 철판 외곽선 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
|
||||
<lineBasicMaterial color="#000000" opacity={0.6} transparent linewidth={1.5} />
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
|
||||
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
|
||||
{plateCount > 0 && (
|
||||
<Text
|
||||
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||
color="#374151"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{`${plateCount}장`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
|
||||
{placement.material_name && (
|
||||
<Text
|
||||
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#1f2937"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{placement.material_name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1114,20 +1119,11 @@ function Scene({
|
|||
{/* 배경색 */}
|
||||
<color attach="background" args={["#f3f4f6"]} />
|
||||
|
||||
{/* 바닥 그리드 (타일을 4등분) */}
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
||||
cellThickness={0.6}
|
||||
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
||||
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
||||
sectionThickness={1.5}
|
||||
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
||||
fadeDistance={200}
|
||||
fadeStrength={1}
|
||||
followCamera={false}
|
||||
infiniteGrid={true}
|
||||
/>
|
||||
{/* 바닥 - 단색 평면 (그리드 제거) */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
|
||||
<planeGeometry args={[200, 200]} />
|
||||
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
|
||||
</mesh>
|
||||
|
||||
{/* 자재 박스들 */}
|
||||
{placements.map((placement) => (
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
|
||||
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식
|
||||
|
||||
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||
const dataSources = useMemo(() => {
|
||||
|
|
@ -122,9 +122,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}, []);
|
||||
|
||||
// 이동경로 로드 함수
|
||||
const loadRoute = useCallback(async (userId: string, date?: string) => {
|
||||
const loadRoute = useCallback(
|
||||
async (userId: string, date?: string) => {
|
||||
if (!userId) {
|
||||
console.log("🛣️ 이동경로 조회 불가: userId 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,8 +144,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
AND recorded_at <= '${endOfDay}'
|
||||
ORDER BY recorded_at ASC`;
|
||||
|
||||
console.log("🛣️ 이동경로 쿼리:", query);
|
||||
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -164,20 +162,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
recordedAt: row.recorded_at,
|
||||
}));
|
||||
|
||||
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
||||
setRoutePoints(points);
|
||||
} else {
|
||||
console.log("🛣️ 이동경로 데이터 없음");
|
||||
setRoutePoints([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("이동경로 로드 실패:", error);
|
||||
} catch {
|
||||
setRoutePoints([]);
|
||||
}
|
||||
|
||||
setRouteLoading(false);
|
||||
}, [routeDate]);
|
||||
},
|
||||
[routeDate],
|
||||
);
|
||||
|
||||
// 이동경로 숨기기
|
||||
const clearRoute = useCallback(() => {
|
||||
|
|
@ -297,6 +294,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
});
|
||||
}
|
||||
|
||||
// Request Body 파싱
|
||||
let requestBody: any = undefined;
|
||||
if (source.body) {
|
||||
try {
|
||||
requestBody = JSON.parse(source.body);
|
||||
} catch {
|
||||
// JSON 파싱 실패시 문자열 그대로 사용
|
||||
requestBody = source.body;
|
||||
}
|
||||
}
|
||||
|
||||
// 백엔드 프록시를 통해 API 호출
|
||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||
method: "POST",
|
||||
|
|
@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
method: source.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
body: requestBody,
|
||||
externalConnectionId: source.externalConnectionId,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -344,14 +354,81 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}
|
||||
}
|
||||
|
||||
// 데이터가 null/undefined면 빈 결과 반환
|
||||
if (data === null || data === undefined) {
|
||||
return { markers: [], polygons: [] };
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||
const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
return finalResult;
|
||||
const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
|
||||
// ✅ REST API 데이터를 vehicle_location_history에 자동 저장 (경로 보기용)
|
||||
// - 모든 REST API 차량 위치 데이터는 자동으로 저장됨
|
||||
if (mapData.markers.length > 0) {
|
||||
try {
|
||||
const authToken = typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : "";
|
||||
|
||||
// 마커 데이터를 vehicle_location_history에 저장
|
||||
for (const marker of mapData.markers) {
|
||||
// user_id 추출 (마커 description에서 파싱)
|
||||
let userId = "";
|
||||
let vehicleId: number | undefined = undefined;
|
||||
let vehicleName = "";
|
||||
|
||||
if (marker.description) {
|
||||
try {
|
||||
const parsed = JSON.parse(marker.description);
|
||||
// 다양한 필드명 지원 (plate_no 우선 - 차량 번호판으로 경로 구분)
|
||||
userId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
|
||||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
|
||||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
|
||||
parsed.id || parsed.code || "";
|
||||
vehicleId = parsed.vehicle_id || parsed.vehicleId || parsed.car_id || parsed.carId;
|
||||
vehicleName = parsed.plate_no || parsed.plateNo || parsed.car_name || parsed.carName ||
|
||||
parsed.vehicle_name || parsed.vehicleName || parsed.name || parsed.title || "";
|
||||
} catch {
|
||||
// 파싱 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
// user_id가 없으면 마커 이름이나 ID를 사용
|
||||
if (!userId) {
|
||||
userId = marker.name || marker.id || `marker_${Date.now()}`;
|
||||
}
|
||||
|
||||
// vehicle_location_history에 저장
|
||||
await fetch(getApiUrl("/api/dynamic-form/location-history"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
latitude: marker.lat,
|
||||
longitude: marker.lng,
|
||||
userId: userId,
|
||||
vehicleId: vehicleId,
|
||||
tripStatus: "api_tracking", // REST API에서 가져온 데이터 표시
|
||||
departureName: source.name || "REST API",
|
||||
destinationName: vehicleName || marker.name,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("📍 [saveToHistory] 저장 완료:", { userId, lat: marker.lat, lng: marker.lng });
|
||||
}
|
||||
} catch (saveError) {
|
||||
console.error("❌ [saveToHistory] 저장 실패:", saveError);
|
||||
// 저장 실패해도 마커 표시는 계속
|
||||
}
|
||||
}
|
||||
|
||||
return mapData;
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
|
|
@ -485,6 +562,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const polygons: PolygonData[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
// null/undefined 체크
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
||||
const parsedData = parseTextData(row.text);
|
||||
|
|
@ -1098,13 +1180,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
}}
|
||||
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-blue-600">
|
||||
({routePoints.length}개)
|
||||
</span>
|
||||
<button
|
||||
onClick={clearRoute}
|
||||
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
<span className="text-xs text-blue-600">({routePoints.length}개)</span>
|
||||
<button onClick={clearRoute} className="ml-1 text-xs text-blue-400 hover:text-blue-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1645,18 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(marker.description || "{}");
|
||||
const userId = parsed.user_id;
|
||||
if (userId) {
|
||||
// 다양한 필드명 지원 (plate_no 우선)
|
||||
const visibleUserId = parsed.plate_no || parsed.plateNo || parsed.car_number || parsed.carNumber ||
|
||||
parsed.user_id || parsed.userId || parsed.driver_id || parsed.driverId ||
|
||||
parsed.car_no || parsed.carNo || parsed.vehicle_no || parsed.vehicleNo ||
|
||||
parsed.id || parsed.code || marker.name;
|
||||
if (visibleUserId) {
|
||||
return (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<button
|
||||
onClick={() => loadRoute(userId)}
|
||||
onClick={() => loadRoute(visibleUserId)}
|
||||
disabled={routeLoading}
|
||||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{routeLoading && selectedUserId === userId
|
||||
? "로딩 중..."
|
||||
: "🛣️ 이동경로 보기"}
|
||||
{routeLoading && selectedUserId === visibleUserId ? "로딩 중..." : "🛣️ 이동경로 보기"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1953,6 +1953,139 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 버튼 활성화 조건 설정 */}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<h5 className="mb-3 text-xs font-medium text-muted-foreground">버튼 활성화 조건</h5>
|
||||
|
||||
{/* 출발지/도착지 필수 체크 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="require-location">출발지/도착지 필수</Label>
|
||||
<p className="text-xs text-muted-foreground">선택하지 않으면 버튼 비활성화</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="require-location"
|
||||
checked={config.action?.requireLocationFields === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.requireLocationFields", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.action?.requireLocationFields && (
|
||||
<div className="mt-3 space-y-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>출발지 필드명</Label>
|
||||
<Input
|
||||
placeholder="departure"
|
||||
value={config.action?.trackingDepartureField || "departure"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.trackingDepartureField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>도착지 필드명</Label>
|
||||
<Input
|
||||
placeholder="destination"
|
||||
value={config.action?.trackingArrivalField || "destination"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.trackingArrivalField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상태 기반 활성화 조건 */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="enable-on-status">상태 기반 활성화</Label>
|
||||
<p className="text-xs text-muted-foreground">특정 상태일 때만 버튼 활성화</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-on-status"
|
||||
checked={config.action?.enableOnStatusCheck === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.enableOnStatusCheck", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.action?.enableOnStatusCheck && (
|
||||
<div className="mt-3 space-y-2 rounded-md bg-purple-50 p-3 dark:bg-purple-950">
|
||||
<div>
|
||||
<Label>상태 조회 테이블</Label>
|
||||
<Select
|
||||
value={config.action?.statusCheckTableName || "vehicles"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusCheckTableName", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
상태를 조회할 테이블 (기본: vehicles)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>조회 키 필드</Label>
|
||||
<Input
|
||||
placeholder="user_id"
|
||||
value={config.action?.statusCheckKeyField || "user_id"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckKeyField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
현재 로그인 사용자 ID로 조회할 필드 (기본: user_id)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>상태 컬럼명</Label>
|
||||
<Input
|
||||
placeholder="status"
|
||||
value={config.action?.statusCheckField || "status"}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.statusCheckField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
상태 값이 저장된 컬럼명 (기본: status)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>상태 조건</Label>
|
||||
<Select
|
||||
value={config.action?.statusConditionType || "enableOn"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.statusConditionType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enableOn">이 상태일 때만 활성화</SelectItem>
|
||||
<SelectItem value="disableOn">이 상태일 때 비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>상태값 (쉼표로 구분)</Label>
|
||||
<Input
|
||||
placeholder="예: active, inactive"
|
||||
value={config.action?.statusConditionValues || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.statusConditionValues", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
여러 상태값은 쉼표(,)로 구분
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 예시:</strong>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ export interface BatchConfig {
|
|||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
save_mode?: 'INSERT' | 'UPSERT'; // 저장 모드 (기본: INSERT)
|
||||
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
|
||||
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
|
|
@ -386,6 +389,26 @@ export class BatchAPI {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* auth_tokens 테이블의 서비스명 목록 조회
|
||||
*/
|
||||
static async getAuthServiceNames(): Promise<string[]> {
|
||||
try {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: string[];
|
||||
}>(`/batch-management/auth-services`);
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.data || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("인증 서비스 목록 조회 오류:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BatchJob export 추가 (이미 위에서 interface로 정의됨)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { apiClient } from "./client";
|
|||
|
||||
// 배치관리 전용 타입 정의
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
type: "internal" | "external";
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
|
|
@ -39,9 +39,7 @@ class BatchManagementAPIClass {
|
|||
*/
|
||||
static async getAvailableConnections(): Promise<BatchConnectionInfo[]> {
|
||||
try {
|
||||
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(
|
||||
`${this.BASE_PATH}/connections`
|
||||
);
|
||||
const response = await apiClient.get<BatchApiResponse<BatchConnectionInfo[]>>(`${this.BASE_PATH}/connections`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "커넥션 목록 조회에 실패했습니다.");
|
||||
|
|
@ -58,15 +56,15 @@ class BatchManagementAPIClass {
|
|||
* 특정 커넥션의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionId?: number
|
||||
connectionType: "internal" | "external",
|
||||
connectionId?: number,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||
if (connectionType === 'external' && connectionId) {
|
||||
if (connectionType === "external" && connectionId) {
|
||||
url += `/${connectionId}`;
|
||||
}
|
||||
url += '/tables';
|
||||
url += "/tables";
|
||||
|
||||
const response = await apiClient.get<BatchApiResponse<string[]>>(url);
|
||||
|
||||
|
|
@ -85,13 +83,13 @@ class BatchManagementAPIClass {
|
|||
* 특정 테이블의 컬럼 정보 조회
|
||||
*/
|
||||
static async getTableColumns(
|
||||
connectionType: 'internal' | 'external',
|
||||
connectionType: "internal" | "external",
|
||||
tableName: string,
|
||||
connectionId?: number
|
||||
connectionId?: number,
|
||||
): Promise<BatchColumnInfo[]> {
|
||||
try {
|
||||
let url = `${this.BASE_PATH}/connections/${connectionType}`;
|
||||
if (connectionType === 'external' && connectionId) {
|
||||
if (connectionType === "external" && connectionId) {
|
||||
url += `/${connectionId}`;
|
||||
}
|
||||
url += `/tables/${encodeURIComponent(tableName)}/columns`;
|
||||
|
|
@ -120,14 +118,16 @@ class BatchManagementAPIClass {
|
|||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||
paramInfo?: {
|
||||
paramType: 'url' | 'query';
|
||||
paramType: "url" | "query";
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
paramSource: 'static' | 'dynamic';
|
||||
paramSource: "static" | "dynamic";
|
||||
},
|
||||
requestBody?: string
|
||||
requestBody?: string,
|
||||
authServiceName?: string, // DB에서 토큰 가져올 서비스명
|
||||
dataArrayPath?: string, // 데이터 배열 경로 (예: response, data.items)
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
|
|
@ -139,7 +139,7 @@ class BatchManagementAPIClass {
|
|||
apiKey,
|
||||
endpoint,
|
||||
method,
|
||||
requestBody
|
||||
requestBody,
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
|
|
@ -150,11 +150,23 @@ class BatchManagementAPIClass {
|
|||
requestData.paramSource = paramInfo.paramSource;
|
||||
}
|
||||
|
||||
const response = await apiClient.post<BatchApiResponse<{
|
||||
// DB에서 토큰 가져올 서비스명 추가
|
||||
if (authServiceName) {
|
||||
requestData.authServiceName = authServiceName;
|
||||
}
|
||||
|
||||
// 데이터 배열 경로 추가
|
||||
if (dataArrayPath) {
|
||||
requestData.dataArrayPath = dataArrayPath;
|
||||
}
|
||||
|
||||
const response = await apiClient.post<
|
||||
BatchApiResponse<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
totalCount: number;
|
||||
}>>(`${this.BASE_PATH}/rest-api/preview`, requestData);
|
||||
}>
|
||||
>(`${this.BASE_PATH}/rest-api/preview`, requestData);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "REST API 미리보기에 실패했습니다.");
|
||||
|
|
@ -167,6 +179,24 @@ class BatchManagementAPIClass {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 서비스명 목록 조회
|
||||
*/
|
||||
static async getAuthServiceNames(): Promise<string[]> {
|
||||
try {
|
||||
const response = await apiClient.get<BatchApiResponse<string[]>>(`${this.BASE_PATH}/auth-services`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "인증 서비스 목록 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error("인증 서비스 목록 조회 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 배치 저장
|
||||
*/
|
||||
|
|
@ -176,15 +206,17 @@ class BatchManagementAPIClass {
|
|||
cronSchedule: string;
|
||||
description?: string;
|
||||
apiMappings: any[];
|
||||
}): Promise<{ success: boolean; message: string; data?: any; }> {
|
||||
authServiceName?: string;
|
||||
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
|
||||
saveMode?: "INSERT" | "UPSERT";
|
||||
conflictKey?: string;
|
||||
}): Promise<{ success: boolean; message: string; data?: any }> {
|
||||
try {
|
||||
const response = await apiClient.post<BatchApiResponse<any>>(
|
||||
`${this.BASE_PATH}/rest-api/save`, batchData
|
||||
);
|
||||
const response = await apiClient.post<BatchApiResponse<any>>(`${this.BASE_PATH}/rest-api/save`, batchData);
|
||||
return {
|
||||
success: response.data.success,
|
||||
message: response.data.message || "",
|
||||
data: response.data.data
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("REST API 배치 저장 오류:", error);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export interface ExternalRestApiConnection {
|
|||
retry_delay?: number;
|
||||
company_code: string;
|
||||
is_active: string;
|
||||
|
||||
// 위치 이력 저장 설정 (지도 위젯용)
|
||||
save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
|||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
|
|
@ -148,6 +149,149 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
return result;
|
||||
}, [flowConfig, currentStep, component.id, component.label]);
|
||||
|
||||
// 🆕 운행알림 버튼 조건부 비활성화 (출발지/도착지 필수, 상태 체크)
|
||||
// 상태는 API로 조회 (formData에 없는 경우)
|
||||
const [vehicleStatus, setVehicleStatus] = useState<string | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
|
||||
// 상태 조회 (operation_control + enableOnStatusCheck일 때)
|
||||
const actionConfig = component.componentConfig?.action;
|
||||
const shouldFetchStatus = actionConfig?.type === "operation_control" && actionConfig?.enableOnStatusCheck && userId;
|
||||
const statusTableName = actionConfig?.statusCheckTableName || "vehicles";
|
||||
const statusKeyField = actionConfig?.statusCheckKeyField || "user_id";
|
||||
const statusFieldName = actionConfig?.statusCheckField || "status";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchStatus) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||||
page: 1,
|
||||
size: 1,
|
||||
search: { [statusKeyField]: userId },
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||
const firstRow = Array.isArray(rows) ? rows[0] : null;
|
||||
|
||||
if (response.data?.success && firstRow) {
|
||||
const newStatus = firstRow[statusFieldName];
|
||||
if (newStatus !== vehicleStatus) {
|
||||
// console.log("🔄 [ButtonPrimary] 상태 변경 감지:", { 이전: vehicleStatus, 현재: newStatus, buttonLabel: component.label });
|
||||
}
|
||||
setVehicleStatus(newStatus);
|
||||
} else {
|
||||
setVehicleStatus(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// console.error("❌ [ButtonPrimary] 상태 조회 오류:", error?.message);
|
||||
if (isMounted) setVehicleStatus(null);
|
||||
} finally {
|
||||
if (isMounted) setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 즉시 실행
|
||||
setStatusLoading(true);
|
||||
fetchStatus();
|
||||
|
||||
// 2초마다 갱신
|
||||
const interval = setInterval(fetchStatus, 2000);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [shouldFetchStatus, statusTableName, statusKeyField, statusFieldName, userId, component.label]);
|
||||
|
||||
// 버튼 비활성화 조건 계산
|
||||
const isOperationButtonDisabled = useMemo(() => {
|
||||
const actionConfig = component.componentConfig?.action;
|
||||
|
||||
if (actionConfig?.type !== "operation_control") return false;
|
||||
|
||||
// 1. 출발지/도착지 필수 체크
|
||||
if (actionConfig?.requireLocationFields) {
|
||||
const departureField = actionConfig.trackingDepartureField || "departure";
|
||||
const destinationField = actionConfig.trackingArrivalField || "destination";
|
||||
|
||||
const departure = formData?.[departureField];
|
||||
const destination = formData?.[destinationField];
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
|
||||
// departureField, destinationField, departure, destination,
|
||||
// buttonLabel: component.label
|
||||
// });
|
||||
|
||||
if (!departure || departure === "" || !destination || destination === "") {
|
||||
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 상태 기반 활성화 조건 (API로 조회한 vehicleStatus 우선 사용)
|
||||
if (actionConfig?.enableOnStatusCheck) {
|
||||
const statusField = actionConfig.statusCheckField || "status";
|
||||
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
|
||||
const currentStatus = vehicleStatus || formData?.[statusField];
|
||||
|
||||
const conditionType = actionConfig.statusConditionType || "enableOn";
|
||||
const conditionValues = (actionConfig.statusConditionValues || "")
|
||||
.split(",")
|
||||
.map((v: string) => v.trim())
|
||||
.filter((v: string) => v);
|
||||
|
||||
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
|
||||
// statusField,
|
||||
// formDataStatus: formData?.[statusField],
|
||||
// apiStatus: vehicleStatus,
|
||||
// currentStatus,
|
||||
// conditionType,
|
||||
// conditionValues,
|
||||
// buttonLabel: component.label,
|
||||
// });
|
||||
|
||||
// 상태 로딩 중이면 비활성화
|
||||
if (statusLoading) {
|
||||
// console.log("⏳ [ButtonPrimary] 상태 로딩 중 → 비활성화:", component.label);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 상태값이 없으면 → 비활성화 (조건 확인 불가)
|
||||
if (!currentStatus) {
|
||||
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (conditionValues.length > 0) {
|
||||
if (conditionType === "enableOn") {
|
||||
// 이 상태일 때만 활성화
|
||||
if (!conditionValues.includes(currentStatus)) {
|
||||
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∉ [${conditionValues}] → 비활성화:`, component.label);
|
||||
return true;
|
||||
}
|
||||
} else if (conditionType === "disableOn") {
|
||||
// 이 상태일 때 비활성화
|
||||
if (conditionValues.includes(currentStatus)) {
|
||||
// console.log(`🚫 [ButtonPrimary] 상태 ${currentStatus} ∈ [${conditionValues}] → 비활성화:`, component.label);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// console.log("✅ [ButtonPrimary] 버튼 활성화:", component.label);
|
||||
return false;
|
||||
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
|
||||
|
||||
// 확인 다이얼로그 상태
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<{
|
||||
|
|
@ -877,6 +1021,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화)
|
||||
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading;
|
||||
|
||||
// 공통 버튼 스타일
|
||||
const buttonElementStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -884,12 +1031,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
minHeight: "40px",
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
background: componentConfig.disabled ? "#e5e7eb" : buttonColor,
|
||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
||||
background: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||
color: finalDisabled ? "#9ca3af" : "white",
|
||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||
fontWeight: "600",
|
||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||
cursor: finalDisabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
display: "flex",
|
||||
|
|
@ -900,7 +1047,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||
margin: "0",
|
||||
lineHeight: "1.25",
|
||||
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
|
||||
...(component.style ? Object.fromEntries(
|
||||
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
|
||||
|
|
@ -925,7 +1072,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 일반 모드: button으로 렌더링
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={componentConfig.disabled || false}
|
||||
disabled={finalDisabled}
|
||||
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
|
||||
style={buttonElementStyle}
|
||||
onClick={handleClick}
|
||||
|
|
|
|||
|
|
@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDestination}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDestination && " (도착지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
|||
</SelectTrigger>
|
||||
<SelectContent position="popper" sideOffset={4}>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.value === localDeparture}
|
||||
>
|
||||
{option.label}
|
||||
{option.value === localDeparture && " (출발지)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,7 +10,7 @@ import { TableListConfig, ColumnConfig } from "./types";
|
|||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
|
||||
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -1141,8 +1141,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
||||
{/* 편집 가능 여부 + 필터 체크박스 */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* 🆕 편집 가능 여부 토글 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateColumn(column.columnName, {
|
||||
editable: column.editable === false ? true : false
|
||||
});
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title={column.editable === false ? "편집 불가 (클릭하여 편집 허용)" : "편집 가능 (클릭하여 편집 잠금)"}
|
||||
>
|
||||
{column.editable === false ? (
|
||||
<Lock className="h-3 w-3 text-destructive" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 필터 체크박스 */}
|
||||
<Checkbox
|
||||
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -1173,6 +1193,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
title="필터에 추가"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export interface ColumnConfig {
|
|||
// 새로운 기능들
|
||||
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||
editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가)
|
||||
|
||||
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
|
||||
additionalJoinInfo?: {
|
||||
|
|
|
|||
|
|
@ -3613,6 +3613,112 @@ export class ButtonActionExecutor {
|
|||
|
||||
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
|
||||
|
||||
// 🆕 거리/시간 계산 및 저장
|
||||
if (tripId) {
|
||||
try {
|
||||
const tripStats = await this.calculateTripStats(tripId);
|
||||
console.log("📊 운행 통계:", tripStats);
|
||||
|
||||
// 운행 통계를 두 테이블에 저장
|
||||
if (tripStats) {
|
||||
const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m
|
||||
const timeMinutes = tripStats.totalTimeMinutes;
|
||||
const userId = this.trackingUserId || context.userId;
|
||||
|
||||
console.log("💾 운행 통계 DB 저장 시도:", {
|
||||
tripId,
|
||||
userId,
|
||||
distanceMeters,
|
||||
timeMinutes,
|
||||
startTime: tripStats.startTime,
|
||||
endTime: tripStats.endTime,
|
||||
});
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용)
|
||||
try {
|
||||
const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
|
||||
page: 1,
|
||||
size: 1,
|
||||
search: { trip_id: tripId },
|
||||
sortBy: "recorded_at",
|
||||
sortOrder: "desc",
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || [];
|
||||
if (lastRecordData.length > 0) {
|
||||
const lastRecordId = lastRecordData[0].id;
|
||||
console.log("📍 마지막 레코드 ID:", lastRecordId);
|
||||
|
||||
const historyUpdates = [
|
||||
{ field: "trip_distance", value: distanceMeters },
|
||||
{ field: "trip_time", value: timeMinutes },
|
||||
{ field: "trip_start", value: tripStats.startTime },
|
||||
{ field: "trip_end", value: tripStats.endTime },
|
||||
];
|
||||
|
||||
for (const update of historyUpdates) {
|
||||
await apiClient.put(`/dynamic-form/update-field`, {
|
||||
tableName: "vehicle_location_history",
|
||||
keyField: "id",
|
||||
keyValue: lastRecordId,
|
||||
updateField: update.field,
|
||||
updateValue: update.value,
|
||||
});
|
||||
}
|
||||
console.log("✅ vehicle_location_history 통계 저장 완료");
|
||||
} else {
|
||||
console.warn("⚠️ trip_id에 해당하는 레코드를 찾을 수 없음:", tripId);
|
||||
}
|
||||
} catch (historyError) {
|
||||
console.warn("⚠️ vehicle_location_history 저장 실패:", historyError);
|
||||
}
|
||||
|
||||
// 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용)
|
||||
if (userId) {
|
||||
try {
|
||||
const vehicleUpdates = [
|
||||
{ field: "last_trip_distance", value: distanceMeters },
|
||||
{ field: "last_trip_time", value: timeMinutes },
|
||||
{ field: "last_trip_start", value: tripStats.startTime },
|
||||
{ field: "last_trip_end", value: tripStats.endTime },
|
||||
];
|
||||
|
||||
for (const update of vehicleUpdates) {
|
||||
await apiClient.put(`/dynamic-form/update-field`, {
|
||||
tableName: "vehicles",
|
||||
keyField: "user_id",
|
||||
keyValue: userId,
|
||||
updateField: update.field,
|
||||
updateValue: update.value,
|
||||
});
|
||||
}
|
||||
console.log("✅ vehicles 테이블 통계 업데이트 완료");
|
||||
} catch (vehicleError) {
|
||||
console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트로 통계 전달 (UI에서 표시용)
|
||||
window.dispatchEvent(new CustomEvent("tripCompleted", {
|
||||
detail: {
|
||||
tripId,
|
||||
totalDistanceKm: tripStats.totalDistanceKm,
|
||||
totalTimeMinutes: tripStats.totalTimeMinutes,
|
||||
startTime: tripStats.startTime,
|
||||
endTime: tripStats.endTime,
|
||||
}
|
||||
}));
|
||||
|
||||
toast.success(`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`);
|
||||
}
|
||||
} catch (statsError) {
|
||||
console.warn("⚠️ 운행 통계 계산 실패:", statsError);
|
||||
}
|
||||
}
|
||||
|
||||
// 상태 변경 (vehicles 테이블 등)
|
||||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
|
||||
const effectiveContext = context.userId ? context : this.trackingContext;
|
||||
|
|
@ -3662,6 +3768,104 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 통계 계산 (거리, 시간)
|
||||
*/
|
||||
private static async calculateTripStats(tripId: string): Promise<{
|
||||
totalDistanceKm: number;
|
||||
totalTimeMinutes: number;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
} | null> {
|
||||
try {
|
||||
// vehicle_location_history에서 해당 trip의 모든 위치 조회
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
|
||||
page: 1,
|
||||
size: 10000,
|
||||
search: { trip_id: tripId },
|
||||
sortBy: "recorded_at",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
|
||||
if (!response.data?.success) {
|
||||
console.log("📊 통계 계산: API 응답 실패");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 응답 형식: data.data.data 또는 data.data.rows
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
|
||||
if (!rows.length) {
|
||||
console.log("📊 통계 계산: 데이터 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
const locations = rows;
|
||||
console.log(`📊 통계 계산: ${locations.length}개 위치 데이터`);
|
||||
|
||||
// 시간 계산
|
||||
const startTime = locations[0].recorded_at;
|
||||
const endTime = locations[locations.length - 1].recorded_at;
|
||||
const totalTimeMs = new Date(endTime).getTime() - new Date(startTime).getTime();
|
||||
const totalTimeMinutes = Math.round(totalTimeMs / 60000);
|
||||
|
||||
// 거리 계산 (Haversine 공식)
|
||||
let totalDistanceM = 0;
|
||||
for (let i = 1; i < locations.length; i++) {
|
||||
const prev = locations[i - 1];
|
||||
const curr = locations[i];
|
||||
|
||||
if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) {
|
||||
const distance = this.calculateDistance(
|
||||
parseFloat(prev.latitude),
|
||||
parseFloat(prev.longitude),
|
||||
parseFloat(curr.latitude),
|
||||
parseFloat(curr.longitude)
|
||||
);
|
||||
totalDistanceM += distance;
|
||||
}
|
||||
}
|
||||
|
||||
const totalDistanceKm = totalDistanceM / 1000;
|
||||
|
||||
console.log("📊 운행 통계 결과:", {
|
||||
tripId,
|
||||
totalDistanceKm,
|
||||
totalTimeMinutes,
|
||||
startTime,
|
||||
endTime,
|
||||
pointCount: locations.length,
|
||||
});
|
||||
|
||||
return {
|
||||
totalDistanceKm,
|
||||
totalTimeMinutes,
|
||||
startTime,
|
||||
endTime,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ 운행 통계 계산 오류:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 좌표 간 거리 계산 (Haversine 공식, 미터 단위)
|
||||
*/
|
||||
private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000; // 지구 반경 (미터)
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
||||
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||||
|
|
@ -4217,6 +4421,28 @@ export class ButtonActionExecutor {
|
|||
try {
|
||||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||||
|
||||
// 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만)
|
||||
// updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우
|
||||
const isStartMode = config.updateTrackingMode === "start" ||
|
||||
config.updateTargetValue === "active" ||
|
||||
config.updateTargetValue === "inactive";
|
||||
|
||||
if (isStartMode) {
|
||||
// 출발지/도착지 필드명 (기본값: departure, destination)
|
||||
const departureField = config.trackingDepartureField || "departure";
|
||||
const destinationField = config.trackingArrivalField || "destination";
|
||||
|
||||
const departure = context.formData?.[departureField];
|
||||
const destination = context.formData?.[destinationField];
|
||||
|
||||
console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination });
|
||||
|
||||
if (!departure || departure === "" || !destination || destination === "") {
|
||||
toast.error("출발지와 도착지를 먼저 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
||||
if (this.emptyVehicleWatchId !== null) {
|
||||
this.stopEmptyVehicleTracking();
|
||||
|
|
|
|||
Loading…
Reference in New Issue