diff --git a/.cursor/rules/table-list-component-guide.mdc b/.cursor/rules/table-list-component-guide.mdc new file mode 100644 index 00000000..5d3f0e1f --- /dev/null +++ b/.cursor/rules/table-list-component-guide.mdc @@ -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; + columnOrder: string[]; + sortBy: string; + sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +### 14. 그룹화 및 그룹 소계 + +```typescript +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; + summary?: Record; +} +``` + +### 15. 총계 요약 (Total Summary) + +- 숫자 컬럼의 합계, 평균, 개수 표시 +- 테이블 하단에 요약 행 렌더링 + +--- + +## 캐싱 전략 + +```typescript +// 테이블 컬럼 캐시 +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 + +// API 호출 디바운싱 +const debouncedApiCall = ( + key: string, + fn: (...args: T) => Promise, + 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([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 관련 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; +} | null>(null); +const [editingValue, setEditingValue] = useState(""); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 관련 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); +const [searchHighlights, setSearchHighlights] = useState>(new Map()); + +// 컬럼 관련 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); +const [frozenColumns, setFrozenColumns] = useState([]); + +// 선택 관련 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + +// 정렬 관련 +const [sortBy, setSortBy] = useState(""); +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 && ( + +)} + +// 셀 배경색 +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` - 스티키 헤더 테이블 diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d0b22db4..a03478b9 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -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( @@ -753,6 +761,7 @@ export class DashboardController { res.status(200).json({ success: true, data, + connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 30364189..97cd2cc1 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -492,7 +492,7 @@ export const saveLocationHistory = async ( res: Response ): Promise => { 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, diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 2632a6e6..6f0b1239 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -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); diff --git a/backend-node/src/types/externalRestApiTypes.ts b/backend-node/src/types/externalRestApiTypes.ts index 8d95a4a6..416cbe6f 100644 --- a/backend-node/src/types/externalRestApiTypes.ts +++ b/backend-node/src/types/externalRestApiTypes.ts @@ -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; diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 3de34800..606e3874 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -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 }: 활성 상태 + +
+ + + + (지도 위젯에서 이 API 데이터를 vehicle_location_history에 저장) + +
{/* 헤더 관리 */} diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index 86da8fe7..f92e440a 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -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 )} + {/* 위치 이력 저장 설정 (지도 위젯용) */} +
+
+ +

+ REST API에서 가져온 위치 데이터를 vehicle_location_history에 저장합니다 +

+
+ onChange({ saveToHistory: checked })} + /> +
+ {/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */} {testResult?.success && availableColumns.length > 0 && (
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 19599b69..bc52ecb8 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -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 { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 94c3a217..9b0db43a 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -365,7 +365,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const mappedRows = applyColumnMapping(rows, source.columnMapping); // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) - return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); + 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 데이터 로딩 @@ -1659,16 +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 (
); diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts index f907ee85..d58545f6 100644 --- a/frontend/lib/api/externalRestApiConnection.ts +++ b/frontend/lib/api/externalRestApiConnection.ts @@ -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; diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 5dc4a165..3f1a723b 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -343,8 +343,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -387,8 +392,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -419,8 +429,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -451,8 +466,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))} @@ -479,8 +499,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDestination && " (도착지)"} ))} @@ -508,8 +533,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {options.map((option) => ( - + {option.label} + {option.value === localDeparture && " (출발지)"} ))}