diff --git a/backend-node/.env.example b/backend-node/.env.example index fdba2895..807ae916 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file MAINTENANCE_DATA_SOURCE=memory DOCUMENT_DATA_SOURCE=memory + +# OpenWeatherMap API 키 추가 (실시간 날씨) +# https://openweathermap.org/api 에서 무료 가입 후 발급 +OPENWEATHER_API_KEY=your_openweathermap_api_key_here + diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index f737a833..b84dc218 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -17,19 +17,54 @@ export class OpenApiProxyController { console.log(`🌤️ 날씨 조회 요청: ${city}`); - // 기상청 API Hub 키 확인 + // 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트) + const openWeatherKey = process.env.OPENWEATHER_API_KEY; + if (openWeatherKey) { + try { + console.log(`🌍 OpenWeatherMap API 호출: ${city}`); + const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { + params: { + q: `${city},KR`, + appid: openWeatherKey, + units: 'metric', + lang: 'kr', + }, + timeout: 10000, + }); + + const data = response.data; + const weatherData = { + city: data.name, + country: data.sys.country, + temperature: Math.round(data.main.temp), + feelsLike: Math.round(data.main.feels_like), + humidity: data.main.humidity, + pressure: data.main.pressure, + weatherMain: data.weather[0].main, + weatherDescription: data.weather[0].description, + weatherIcon: data.weather[0].icon, + windSpeed: Math.round(data.wind.speed * 10) / 10, + clouds: data.clouds.all, + timestamp: new Date().toISOString(), + }; + + console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`); + res.json({ success: true, data: weatherData }); + return; + } catch (error) { + console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error); + } + } + + // 2순위: 기상청 API Hub (매시간 정시 데이터) const apiKey = process.env.KMA_API_KEY; - // API 키가 없으면 테스트 모드로 실시간 날씨 제공 + // API 키가 없으면 오류 반환 if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.'); + res.status(503).json({ + success: false, + message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.', }); return; } @@ -48,32 +83,39 @@ export class OpenApiProxyController { // 기상청 API Hub 사용 (apihub.kma.go.kr) const now = new Date(); - // 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능 - // 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회 - const minute = now.getMinutes(); - let targetTime = new Date(now); + // 한국 시간(KST = UTC+9)으로 변환 + const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 + const kstNow = new Date(now.getTime() + kstOffset); - if (minute < 10) { - // 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로 - targetTime = new Date(now.getTime() - 60 * 60 * 1000); - } + // 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표 + // 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정 + const targetTime = new Date(kstNow); // tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회) - const year = targetTime.getFullYear(); - const month = String(targetTime.getMonth() + 1).padStart(2, '0'); - const day = String(targetTime.getDate()).padStart(2, '0'); - const hour = String(targetTime.getHours()).padStart(2, '0'); + const year = targetTime.getUTCFullYear(); + const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0'); + const day = String(targetTime.getUTCDate()).padStart(2, '0'); + const hour = String(targetTime.getUTCHours()).padStart(2, '0'); const tm = `${year}${month}${day}${hour}00`; + + console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`); - // 기상청 API Hub - 지상관측시간자료 - const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php'; + // 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보) + // sfctm3: 시간 범위 조회 가능 (tm1~tm2) + const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php'; + + // 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준 + const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전 + const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`; + const tm2 = tm; // 현재 시간 - console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`); + console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`); const response = await axios.get(url, { params: { - tm: tm, - stn: 0, // 0 = 전체 관측소 데이터 조회 + tm1: tm1, + tm2: tm2, + stn: regionCode.stnId, // 특정 관측소만 조회 authKey: apiKey, help: 0, disp: 1, @@ -95,30 +137,36 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 날씨 조회 실패:', error); - // API 호출 실패 시 자동으로 테스트 모드로 전환 + // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { const status = error.response?.status; - // 모든 오류 → 테스트 데이터 반환 - console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, - }); + if (status === 401 || status === 403) { + res.status(401).json({ + success: false, + message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.', + }); + } else if (status === 404) { + res.status(404).json({ + success: false, + message: '기상청 API에서 데이터를 찾을 수 없습니다.', + }); + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + res.status(504).json({ + success: false, + message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', + }); + } else { + res.status(500).json({ + success: false, + message: '기상청 API 호출 중 오류가 발생했습니다.', + error: error.message, + }); + } } else { - // 예상치 못한 오류 → 테스트 데이터 반환 - console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + res.status(500).json({ + success: false, + message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } @@ -169,15 +217,19 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 환율 조회 실패:', error); - // API 호출 실패 시 실제 근사값 반환 - console.log('⚠️ API 오류 발생. 근사값을 반환합니다.'); - const { base = 'KRW', target = 'USD' } = req.query; - const approximateRate = generateRealisticExchangeRate(base as string, target as string); - - res.json({ - success: true, - data: approximateRate, - }); + // API 호출 실패 시 명확한 오류 메시지 반환 + if (axios.isAxiosError(error)) { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 오류가 발생했습니다.', + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', + }); + } } } @@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st throw new Error('날씨 데이터를 파싱할 수 없습니다.'); } - // 요청한 관측소(stnId)의 데이터 찾기 - const targetLine = lines.find((line: string) => { + // 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨) + const targetLines = lines.filter((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1) }); - if (!targetLine) { + if (targetLines.length === 0) { throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`); } + + // 가장 최근 데이터 선택 (마지막 줄) + const targetLine = targetLines[targetLines.length - 1]; // 데이터 라인 파싱 (공백으로 구분) const values = targetLine.trim().split(/\s+/); + // 관측 시각 로깅 + const obsTime = values[0]; // YYMMDDHHMI + console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`); + // 기상청 API Hub 데이터 형식 (실제 응답 기준): // [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ... const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11) diff --git a/backend-node/src/routes/vehicleRoutes.ts b/backend-node/src/routes/vehicleRoutes.ts new file mode 100644 index 00000000..b8cfa8ac --- /dev/null +++ b/backend-node/src/routes/vehicleRoutes.ts @@ -0,0 +1,52 @@ +import express from "express"; +import { query } from "../database/db"; + +const router = express.Router(); + +/** + * 차량 위치 자동 업데이트 API + * - 모든 active/warning 상태 차량의 위치를 랜덤하게 조금씩 이동 + */ +router.post("/move", async (req, res) => { + try { + // move_vehicles() 함수 실행 + await query("SELECT move_vehicles()"); + + res.json({ + success: true, + message: "차량 위치가 업데이트되었습니다" + }); + } catch (error) { + console.error("차량 위치 업데이트 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 업데이트 실패" + }); + } +}); + +/** + * 차량 위치 목록 조회 + */ +router.get("/locations", async (req, res) => { + try { + const result = await query(` + SELECT * FROM vehicle_locations + ORDER BY last_update DESC + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error("차량 위치 조회 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 조회 실패" + }); + } +}); + +export default router; + diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 1d8c6fbf..9b93bca6 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -526,12 +526,9 @@ export function CanvasElement({
{element.customTitle || element.title}
- {/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} + {/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */} {onConfigure && - !( - element.type === "widget" && - (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") - ) && ( + !(element.type === "widget" && element.subtype === "driver-management") && (
- {/* 헤더 표시 여부 */} -
+ {/* 헤더 표시 옵션 */} +
setShowHeader(e.target.checked)} className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" /> -
- {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} - {!isSimpleWidget && ( + {/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */} + {!isSimpleWidget && !isHeaderOnlyWidget && (
@@ -247,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {/* 단계별 내용 */} -
- {currentStep === 1 && ( - - )} + {!isHeaderOnlyWidget && ( +
+ {currentStep === 1 && ( + + )} - {currentStep === 2 && ( + {currentStep === 2 && (
{/* 왼쪽: 데이터 설정 */}
@@ -308,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
- )} -
+ )} +
+ )} {/* 모달 푸터 */}
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
- {!isSimpleWidget && currentStep > 1 && ( + {!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && ( - {currentStep === 1 ? ( - // 1단계: 다음 버튼 (모든 타입 공통) + {isHeaderOnlyWidget ? ( + // 헤더 전용 위젯: 바로 저장 + + ) : currentStep === 1 ? ( + // 1단계: 다음 버튼 ) : ( - // 2단계: 저장 버튼 (모든 타입 공통) + // 2단계: 저장 버튼
{/* 진행 상태 표시 */} diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx index 770bb672..480909b8 100644 --- a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx @@ -274,7 +274,7 @@ export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) { return ( - 🏭 창고 현황 (3D) + {element?.customTitle || "창고 현황 (3D)"}
{warehouses.length}개 창고 | {materials.length}개 자재 diff --git a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx index b47f0fb4..cffab99b 100644 --- a/frontend/components/dashboard/widgets/BookingAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/BookingAlertWidget.tsx @@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
-

🔔 {element?.customTitle || "예약 요청 알림"}

+

{element?.customTitle || "예약 요청 알림"}

{newCount > 0 && ( {newCount} diff --git a/frontend/components/dashboard/widgets/CalculatorWidget.tsx b/frontend/components/dashboard/widgets/CalculatorWidget.tsx index fb652fbf..d86c44e3 100644 --- a/frontend/components/dashboard/widgets/CalculatorWidget.tsx +++ b/frontend/components/dashboard/widgets/CalculatorWidget.tsx @@ -172,7 +172,7 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
{/* 제목 */} -

🧮 {element?.customTitle || "계산기"}

+

{element?.customTitle || "계산기"}

{/* 디스플레이 */}
diff --git a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx index f7f50a43..26d6d27d 100644 --- a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx @@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
{/* 헤더 */}
-

⚠️ 고객 클레임/이슈

+

고객 클레임/이슈

diff --git a/frontend/components/dashboard/widgets/ExchangeWidget.tsx b/frontend/components/dashboard/widgets/ExchangeWidget.tsx index d7fb2128..20e0cea7 100644 --- a/frontend/components/dashboard/widgets/ExchangeWidget.tsx +++ b/frontend/components/dashboard/widgets/ExchangeWidget.tsx @@ -139,7 +139,7 @@ export default function ExchangeWidget({ {/* 헤더 */}
-

💱 {element?.customTitle || "환율"}

+

{element?.customTitle || "환율"}

{lastUpdated ? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', { diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index e91746a9..3ad09305 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { {/* 헤더 */}

-

📍 {displayTitle}

+

{displayTitle}

{element?.dataSource?.query ? (

총 {markers.length.toLocaleString()}개 마커

) : ( diff --git a/frontend/components/dashboard/widgets/TodoWidget.tsx b/frontend/components/dashboard/widgets/TodoWidget.tsx index 428e849e..dd4652d5 100644 --- a/frontend/components/dashboard/widgets/TodoWidget.tsx +++ b/frontend/components/dashboard/widgets/TodoWidget.tsx @@ -323,67 +323,71 @@ export default function TodoWidget({ element }: TodoWidgetProps) { return (
- {/* 헤더 */} -
-
-
-

✅ {element?.customTitle || "To-Do / 긴급 지시"}

- {selectedDate && ( -
- - {formatSelectedDate()} 할일 -
- )} -
- -
- - {/* 통계 */} - {stats && ( -
-
-
{stats.pending}
-
대기
-
-
-
{stats.inProgress}
-
진행중
-
-
-
{stats.urgent}
-
긴급
-
-
-
{stats.overdue}
-
지연
-
+ {/* 제목 - 항상 표시 */} +
+

{element?.customTitle || "To-Do / 긴급 지시"}

+ {selectedDate && ( +
+ + {formatSelectedDate()} 할일
)} - - {/* 필터 */} -
- {(["all", "pending", "in_progress", "completed"] as const).map((f) => ( - - ))} -
+ {/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */} + {element?.showHeader !== false && ( +
+
+ +
+ + {/* 통계 */} + {stats && ( +
+
+
{stats.pending}
+
대기
+
+
+
{stats.inProgress}
+
진행중
+
+
+
{stats.urgent}
+
긴급
+
+
+
{stats.overdue}
+
지연
+
+
+ )} + + {/* 필터 */} +
+ {(["all", "pending", "in_progress", "completed"] as const).map((f) => ( + + ))} +
+
+ )} + {/* 추가 폼 */} {showAddForm && (
diff --git a/frontend/components/dashboard/widgets/VehicleListWidget.tsx b/frontend/components/dashboard/widgets/VehicleListWidget.tsx index 1ea927a8..d15d8ffa 100644 --- a/frontend/components/dashboard/widgets/VehicleListWidget.tsx +++ b/frontend/components/dashboard/widgets/VehicleListWidget.tsx @@ -97,7 +97,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }: {/* 헤더 */}
-

📋 차량 목록

+

차량 목록

마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}