Compare commits
10 Commits
e05af3c6f9
...
274078ef2c
| Author | SHA1 | Date |
|---|---|---|
|
|
274078ef2c | |
|
|
7f296afc17 | |
|
|
8ec5c987de | |
|
|
7a596cad3d | |
|
|
c98257a794 | |
|
|
4c4e7965d7 | |
|
|
c39794d1a7 | |
|
|
46ef858c1d | |
|
|
65227c5e03 | |
|
|
417d77729d |
|
|
@ -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, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연결 정보 (응답에 포함용)
|
||||||
|
let connectionInfo: { saveToHistory?: boolean } | null = null;
|
||||||
|
|
||||||
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
// 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
|
||||||
if (externalConnectionId) {
|
if (externalConnectionId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -652,6 +655,11 @@ export class DashboardController {
|
||||||
if (connectionResult.success && connectionResult.data) {
|
if (connectionResult.success && connectionResult.data) {
|
||||||
const connection = connectionResult.data;
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 연결 정보 저장 (응답에 포함)
|
||||||
|
connectionInfo = {
|
||||||
|
saveToHistory: connection.save_to_history === "Y",
|
||||||
|
};
|
||||||
|
|
||||||
// 인증 헤더 생성 (DB 토큰 등)
|
// 인증 헤더 생성 (DB 토큰 등)
|
||||||
const authHeaders =
|
const authHeaders =
|
||||||
await ExternalRestApiConnectionService.getAuthHeaders(
|
await ExternalRestApiConnectionService.getAuthHeaders(
|
||||||
|
|
@ -709,9 +717,9 @@ export class DashboardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
// 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩
|
||||||
const isKmaApi = urlObj.hostname.includes('kma.go.kr');
|
const isKmaApi = urlObj.hostname.includes("kma.go.kr");
|
||||||
if (isKmaApi) {
|
if (isKmaApi) {
|
||||||
requestConfig.responseType = 'arraybuffer';
|
requestConfig.responseType = "arraybuffer";
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios(requestConfig);
|
const response = await axios(requestConfig);
|
||||||
|
|
@ -727,18 +735,22 @@ export class DashboardController {
|
||||||
|
|
||||||
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
// 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR)
|
||||||
if (isKmaApi && Buffer.isBuffer(data)) {
|
if (isKmaApi && Buffer.isBuffer(data)) {
|
||||||
const iconv = require('iconv-lite');
|
const iconv = require("iconv-lite");
|
||||||
const buffer = Buffer.from(data);
|
const buffer = Buffer.from(data);
|
||||||
const utf8Text = buffer.toString('utf-8');
|
const utf8Text = buffer.toString("utf-8");
|
||||||
|
|
||||||
// UTF-8로 정상 디코딩되었는지 확인
|
// UTF-8로 정상 디코딩되었는지 확인
|
||||||
if (utf8Text.includes('특보') || utf8Text.includes('경보') || utf8Text.includes('주의보') ||
|
if (
|
||||||
(utf8Text.includes('#START7777') && !utf8Text.includes('<27>'))) {
|
utf8Text.includes("특보") ||
|
||||||
data = { text: utf8Text, contentType, encoding: 'utf-8' };
|
utf8Text.includes("경보") ||
|
||||||
|
utf8Text.includes("주의보") ||
|
||||||
|
(utf8Text.includes("#START7777") && !utf8Text.includes("<22>"))
|
||||||
|
) {
|
||||||
|
data = { text: utf8Text, contentType, encoding: "utf-8" };
|
||||||
} else {
|
} else {
|
||||||
// EUC-KR로 디코딩
|
// EUC-KR로 디코딩
|
||||||
const eucKrText = iconv.decode(buffer, 'EUC-KR');
|
const eucKrText = iconv.decode(buffer, "EUC-KR");
|
||||||
data = { text: eucKrText, contentType, encoding: 'euc-kr' };
|
data = { text: eucKrText, contentType, encoding: "euc-kr" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 텍스트 응답인 경우 포맷팅
|
// 텍스트 응답인 경우 포맷팅
|
||||||
|
|
@ -749,6 +761,7 @@ export class DashboardController {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
data,
|
data,
|
||||||
|
connectionInfo, // 외부 연결 정보 (saveToHistory 등)
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status || 500;
|
const status = error.response?.status || 500;
|
||||||
|
|
|
||||||
|
|
@ -492,7 +492,7 @@ export const saveLocationHistory = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<Response | void> => {
|
): Promise<Response | void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId: loginUserId } = req.user as any;
|
||||||
const {
|
const {
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
|
@ -508,10 +508,17 @@ export const saveLocationHistory = async (
|
||||||
destinationName,
|
destinationName,
|
||||||
recordedAt,
|
recordedAt,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
|
userId: requestUserId, // 프론트엔드에서 보낸 userId (차량 번호판 등)
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// 프론트엔드에서 보낸 userId가 있으면 그것을 사용 (차량 번호판 등)
|
||||||
|
// 없으면 로그인한 사용자의 userId 사용
|
||||||
|
const userId = requestUserId || loginUserId;
|
||||||
|
|
||||||
console.log("📍 [saveLocationHistory] 요청:", {
|
console.log("📍 [saveLocationHistory] 요청:", {
|
||||||
userId,
|
userId,
|
||||||
|
requestUserId,
|
||||||
|
loginUserId,
|
||||||
companyCode,
|
companyCode,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
|
|
|
||||||
|
|
@ -209,8 +209,8 @@ export class ExternalRestApiConnectionService {
|
||||||
connection_name, description, base_url, endpoint_path, default_headers,
|
connection_name, description, base_url, endpoint_path, default_headers,
|
||||||
default_method, default_request_body,
|
default_method, default_request_body,
|
||||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||||
company_code, is_active, created_by
|
company_code, is_active, created_by, save_to_history
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -230,6 +230,7 @@ export class ExternalRestApiConnectionService {
|
||||||
data.company_code || "*",
|
data.company_code || "*",
|
||||||
data.is_active || "Y",
|
data.is_active || "Y",
|
||||||
data.created_by || "system",
|
data.created_by || "system",
|
||||||
|
data.save_to_history || "N",
|
||||||
];
|
];
|
||||||
|
|
||||||
// 디버깅: 저장하려는 데이터 로깅
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
|
|
@ -377,6 +378,12 @@ export class ExternalRestApiConnectionService {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.save_to_history !== undefined) {
|
||||||
|
updateFields.push(`save_to_history = $${paramIndex}`);
|
||||||
|
params.push(data.save_to_history);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.updated_by !== undefined) {
|
if (data.updated_by !== undefined) {
|
||||||
updateFields.push(`updated_by = $${paramIndex}`);
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
params.push(data.updated_by);
|
params.push(data.updated_by);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ export interface ExternalRestApiConnection {
|
||||||
retry_delay?: number;
|
retry_delay?: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
is_active: string;
|
is_active: string;
|
||||||
|
|
||||||
|
// 위치 이력 저장 설정 (지도 위젯용)
|
||||||
|
save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
const [retryDelay, setRetryDelay] = useState(1000);
|
const [retryDelay, setRetryDelay] = useState(1000);
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [saveToHistory, setSaveToHistory] = useState(false); // 위치 이력 저장 설정
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
@ -80,6 +81,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setRetryCount(connection.retry_count || 0);
|
setRetryCount(connection.retry_count || 0);
|
||||||
setRetryDelay(connection.retry_delay || 1000);
|
setRetryDelay(connection.retry_delay || 1000);
|
||||||
setIsActive(connection.is_active === "Y");
|
setIsActive(connection.is_active === "Y");
|
||||||
|
setSaveToHistory(connection.save_to_history === "Y");
|
||||||
|
|
||||||
// 테스트 초기값 설정
|
// 테스트 초기값 설정
|
||||||
setTestEndpoint("");
|
setTestEndpoint("");
|
||||||
|
|
@ -100,6 +102,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
setRetryCount(0);
|
setRetryCount(0);
|
||||||
setRetryDelay(1000);
|
setRetryDelay(1000);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
|
setSaveToHistory(false);
|
||||||
|
|
||||||
// 테스트 초기값 설정
|
// 테스트 초기값 설정
|
||||||
setTestEndpoint("");
|
setTestEndpoint("");
|
||||||
|
|
@ -234,6 +237,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
retry_delay: retryDelay,
|
retry_delay: retryDelay,
|
||||||
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
// company_code는 백엔드에서 로그인 사용자의 company_code로 자동 설정
|
||||||
is_active: isActive ? "Y" : "N",
|
is_active: isActive ? "Y" : "N",
|
||||||
|
save_to_history: saveToHistory ? "Y" : "N",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("저장하려는 데이터:", {
|
console.log("저장하려는 데이터:", {
|
||||||
|
|
@ -376,6 +380,16 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
활성 상태
|
활성 상태
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="save-to-history" checked={saveToHistory} onCheckedChange={setSaveToHistory} />
|
||||||
|
<Label htmlFor="save-to-history" className="cursor-pointer">
|
||||||
|
위치 이력 저장
|
||||||
|
</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(지도 위젯에서 이 API 데이터를 vehicle_location_history에 저장)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 헤더 관리 */}
|
{/* 헤더 관리 */}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
|
|
||||||
|
|
@ -850,6 +851,23 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 위치 이력 저장 설정 (지도 위젯용) */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="save-to-history" className="text-xs font-semibold cursor-pointer">
|
||||||
|
위치 이력 저장
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="save-to-history"
|
||||||
|
checked={dataSource.saveToHistory || false}
|
||||||
|
onCheckedChange={(checked) => onChange({ saveToHistory: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
||||||
{testResult?.success && availableColumns.length > 0 && (
|
{testResult?.success && availableColumns.length > 0 && (
|
||||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,9 @@ export interface ChartDataSource {
|
||||||
label: string; // 표시할 한글명 (예: 차량 번호)
|
label: string; // 표시할 한글명 (예: 차량 번호)
|
||||||
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
// REST API 위치 데이터 저장 설정 (MapTestWidgetV2용)
|
||||||
|
saveToHistory?: boolean; // REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartConfig {
|
export interface ChartConfig {
|
||||||
|
|
|
||||||
|
|
@ -94,12 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
// 이동경로 상태
|
// 이동경로 상태
|
||||||
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
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 방지)
|
// dataSources를 useMemo로 추출 (circular reference 방지)
|
||||||
const dataSources = useMemo(() => {
|
const dataSources = useMemo(() => {
|
||||||
|
|
@ -122,62 +122,59 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 이동경로 로드 함수
|
// 이동경로 로드 함수
|
||||||
const loadRoute = useCallback(async (userId: string, date?: string) => {
|
const loadRoute = useCallback(
|
||||||
if (!userId) {
|
async (userId: string, date?: string) => {
|
||||||
console.log("🛣️ 이동경로 조회 불가: userId 없음");
|
if (!userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRouteLoading(true);
|
setRouteLoading(true);
|
||||||
setSelectedUserId(userId);
|
setSelectedUserId(userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 선택한 날짜 기준으로 이동경로 조회
|
// 선택한 날짜 기준으로 이동경로 조회
|
||||||
const targetDate = date || routeDate;
|
const targetDate = date || routeDate;
|
||||||
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
const startOfDay = `${targetDate}T00:00:00.000Z`;
|
||||||
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
const endOfDay = `${targetDate}T23:59:59.999Z`;
|
||||||
|
|
||||||
const query = `SELECT latitude, longitude, recorded_at
|
const query = `SELECT latitude, longitude, recorded_at
|
||||||
FROM vehicle_location_history
|
FROM vehicle_location_history
|
||||||
WHERE user_id = '${userId}'
|
WHERE user_id = '${userId}'
|
||||||
AND recorded_at >= '${startOfDay}'
|
AND recorded_at >= '${startOfDay}'
|
||||||
AND recorded_at <= '${endOfDay}'
|
AND recorded_at <= '${endOfDay}'
|
||||||
ORDER BY recorded_at ASC`;
|
ORDER BY recorded_at ASC`;
|
||||||
|
|
||||||
console.log("🛣️ 이동경로 쿼리:", query);
|
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query }),
|
||||||
|
});
|
||||||
|
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
if (response.ok) {
|
||||||
method: "POST",
|
const result = await response.json();
|
||||||
headers: {
|
if (result.success && result.data.rows.length > 0) {
|
||||||
"Content-Type": "application/json",
|
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
||||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
lat: parseFloat(row.latitude),
|
||||||
},
|
lng: parseFloat(row.longitude),
|
||||||
body: JSON.stringify({ query }),
|
recordedAt: row.recorded_at,
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (response.ok) {
|
setRoutePoints(points);
|
||||||
const result = await response.json();
|
} else {
|
||||||
if (result.success && result.data.rows.length > 0) {
|
setRoutePoints([]);
|
||||||
const points: RoutePoint[] = result.data.rows.map((row: any) => ({
|
}
|
||||||
lat: parseFloat(row.latitude),
|
|
||||||
lng: parseFloat(row.longitude),
|
|
||||||
recordedAt: row.recorded_at,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
|
|
||||||
setRoutePoints(points);
|
|
||||||
} else {
|
|
||||||
console.log("🛣️ 이동경로 데이터 없음");
|
|
||||||
setRoutePoints([]);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
setRoutePoints([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("이동경로 로드 실패:", error);
|
|
||||||
setRoutePoints([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setRouteLoading(false);
|
setRouteLoading(false);
|
||||||
}, [routeDate]);
|
},
|
||||||
|
[routeDate],
|
||||||
|
);
|
||||||
|
|
||||||
// 이동경로 숨기기
|
// 이동경로 숨기기
|
||||||
const clearRoute = useCallback(() => {
|
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 호출
|
// 백엔드 프록시를 통해 API 호출
|
||||||
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -309,6 +317,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
method: source.method || "GET",
|
method: source.method || "GET",
|
||||||
headers,
|
headers,
|
||||||
queryParams,
|
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 rows = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
// 컬럼 매핑 적용
|
// 컬럼 매핑 적용
|
||||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||||
|
|
||||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||||
const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
const mapData = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||||
return finalResult;
|
|
||||||
|
// ✅ 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 데이터 로딩
|
// Database 데이터 로딩
|
||||||
|
|
@ -485,6 +562,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const polygons: PolygonData[] = [];
|
const polygons: PolygonData[] = [];
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
|
// null/undefined 체크
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 텍스트 데이터 체크 (기상청 API 등)
|
// 텍스트 데이터 체크 (기상청 API 등)
|
||||||
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
||||||
const parsedData = parseTextData(row.text);
|
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"
|
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">
|
<span className="text-xs text-blue-600">({routePoints.length}개)</span>
|
||||||
({routePoints.length}개)
|
<button onClick={clearRoute} className="ml-1 text-xs text-blue-400 hover:text-blue-600">
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={clearRoute}
|
|
||||||
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
|
|
||||||
>
|
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1409,12 +1486,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 트럭 마커
|
// 트럭 마커
|
||||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||||
const rotation = heading - 90;
|
const rotation = heading - 90;
|
||||||
|
|
||||||
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
|
||||||
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
|
||||||
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
const normalizedRotation = ((rotation % 360) + 360) % 360;
|
||||||
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
|
||||||
const transformStyle = isFlipped
|
const transformStyle = isFlipped
|
||||||
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
|
||||||
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
: `translate(-50%, -50%) rotate(${rotation}deg)`;
|
||||||
|
|
||||||
|
|
@ -1645,18 +1722,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
{(() => {
|
{(() => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(marker.description || "{}");
|
const parsed = JSON.parse(marker.description || "{}");
|
||||||
const userId = parsed.user_id;
|
// 다양한 필드명 지원 (plate_no 우선)
|
||||||
if (userId) {
|
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 (
|
return (
|
||||||
<div className="mt-2 border-t pt-2">
|
<div className="mt-2 border-t pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => loadRoute(userId)}
|
onClick={() => loadRoute(visibleUserId)}
|
||||||
disabled={routeLoading}
|
disabled={routeLoading}
|
||||||
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ export interface ExternalRestApiConnection {
|
||||||
retry_delay?: number;
|
retry_delay?: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
is_active: string;
|
is_active: string;
|
||||||
|
|
||||||
|
// 위치 이력 저장 설정 (지도 위젯용)
|
||||||
|
save_to_history?: string; // 'Y' 또는 'N' - REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장
|
||||||
created_date?: Date;
|
created_date?: Date;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
updated_date?: Date;
|
updated_date?: Date;
|
||||||
|
|
|
||||||
|
|
@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDestination}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDestination && " (도착지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDeparture}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDeparture && " (출발지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDestination}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDestination && " (도착지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDeparture}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDeparture && " (출발지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDestination}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDestination && " (도착지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" sideOffset={4}>
|
<SelectContent position="popper" sideOffset={4}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.value === localDeparture}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{option.value === localDeparture && " (출발지)"}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</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 { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -1143,8 +1143,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
|
{/* 편집 가능 여부 + 필터 체크박스 */}
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<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
|
<Checkbox
|
||||||
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
checked={config.filter?.filters?.some((f) => f.columnName === column.columnName) || false}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -1174,6 +1194,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
|
title="필터에 추가"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export interface ColumnConfig {
|
||||||
// 새로운 기능들
|
// 새로운 기능들
|
||||||
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||||
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
|
editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가)
|
||||||
|
|
||||||
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
|
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
|
||||||
additionalJoinInfo?: {
|
additionalJoinInfo?: {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue