commit 3420bee2bf88489153c1369b3307db486d617cb0 Author: haneulai Date: Fri Jan 30 11:46:03 2026 +0900 Initial commit for SolarPower App diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06e699f --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Expo +.expo/ +*.jks +*.p12 +*.key +*.keystore +*.db + +# Node modules +node_modules/ + +# npm +npm-debug.log +package-lock.json +yarn-error.log + +# Mac +.DS_Store + +# Android +android/ +!android/build.gradle +!android/settings.gradle +!android/app/build.gradle +!android/app/src/main/AndroidManifest.xml + +# iOS +ios/ +!ios/Podfile + +# Web +web-build/ +dist/ + +# Env +.env +.env.local diff --git a/App.js b/App.js new file mode 100644 index 0000000..5b04146 --- /dev/null +++ b/App.js @@ -0,0 +1,500 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + StyleSheet, + View, + Text, + ScrollView, + TouchableOpacity, + ActivityIndicator, + useWindowDimensions, + RefreshControl, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import PlantDetailScreen from './screens/PlantDetailScreen'; + +const API_URL = 'https://solorpower.dadot.net/plants/1'; +const Stack = createNativeStackNavigator(); + +// 메인 대시보드 화면 +function DashboardScreen({ navigation }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const { width } = useWindowDimensions(); + + const isPC = width >= 768; + + const fetchData = useCallback(async () => { + try { + setError(null); + const response = await fetch(API_URL); + if (!response.ok) { + throw new Error(`HTTP Error: ${response.status}`); + } + const result = await response.json(); + setData(result); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const onRefresh = useCallback(() => { + setRefreshing(true); + fetchData(); + }, [fetchData]); + + // 발전소 상태 및 아이콘 결정 + const getStatusInfo = (plant) => { + const latestLog = plant.latest_log; + const currentKw = latestLog?.current_kw || 0; + const status = latestLog?.status; + + if (status === '오류' || status === 'error') { + return { icon: '🔴', label: '오류', color: '#EF4444' }; + } + + // 야간 시간대 확인 (18시 ~ 06시) + const hour = new Date().getHours(); + const isNight = hour >= 18 || hour < 6; + + if (currentKw === 0) { + if (isNight) { + return { icon: '🌙', label: '야간', color: '#4B5563' }; + } + return { icon: '💤', label: '대기', color: '#6B7280' }; + } + + return { icon: '🟢', label: status || '정상', color: '#10B981' }; + }; + + // 요약 데이터 계산 + const getSummary = () => { + if (!data?.data || data.data.length === 0) { + return { totalOutput: 0, operatingRate: 0, totalPlants: 0, activePlants: 0 }; + } + + const plants = data.data; + const totalPlants = plants.length; + let totalOutput = 0; + let activePlants = 0; + + plants.forEach((plant) => { + const currentKw = plant.latest_log?.current_kw || 0; + totalOutput += currentKw; + if (currentKw > 0) { + activePlants++; + } + }); + + const operatingRate = totalPlants > 0 ? (activePlants / totalPlants) * 100 : 0; + + return { totalOutput, operatingRate, totalPlants, activePlants }; + }; + + // 로딩 상태 + if (loading) { + return ( + + + + 데이터를 불러오는 중... + + ); + } + + // 에러 상태 + if (error) { + return ( + + + ⚠️ + 오류가 발생했습니다 + {error} + + 다시 시도 + + + ); + } + + const summary = getSummary(); + // 발전소 이름의 숫자 기준으로 정렬 (예: "태양과바람 1호기" -> 1) + const plants = [...(data?.data || [])].sort((a, b) => { + const getNumber = (str) => { + // 문자열에서 첫 번째 연속된 숫자를 추출 + const match = str && str.match(/(\d+)/); + return match ? parseInt(match[1], 10) : Infinity; + }; + const numA = getNumber(a.name); + const numB = getNumber(b.name); + + if (numA !== numB) return numA - numB; + return (a.name || '').localeCompare(b.name || ''); + }); + + // 발전소 카드 클릭 핸들러 + const handlePlantPress = (plant) => { + navigation.navigate('PlantDetail', { plant }); + }; + + return ( + + + + {/* 상단 헤더 */} + + + ☀️ 태양광 발전 현황 + + 실시간 모니터링 대시보드 + + + {/* 요약 카드 */} + + + 총 출력 + {summary.totalOutput.toFixed(1)} + kW + + + 가동률 + {summary.operatingRate.toFixed(0)} + % + + + 가동 현황 + {summary.activePlants}/{summary.totalPlants} + 발전소 + + + + {/* 발전소 목록 */} + + } + > + {plants.map((plant, index) => { + const statusInfo = getStatusInfo(plant); + const latestLog = plant.latest_log; + const currentKw = latestLog?.current_kw || 0; + const dailyKwh = latestLog?.today_kwh || 0; + + return ( + handlePlantPress(plant)} + activeOpacity={0.7} + > + + {statusInfo.icon} + + {plant.name} + + {statusInfo.label} + + + + + + + + 현재 출력 + {currentKw.toFixed(1)} kW + + + 금일 발전 + {dailyKwh.toFixed(1)} kWh + + {plant.capacity && ( + + 설비 용량 + {plant.capacity} kW + + )} + + + {/* 진행률 바 */} + {plant.capacity && ( + + + + + + {((currentKw / plant.capacity) * 100).toFixed(0)}% + + + )} + + ); + })} + + + ); +} + +// 메인 앱 +export default function App() { + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F3F4F6', + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#F3F4F6', + padding: 20, + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#6B7280', + }, + errorIcon: { + fontSize: 48, + marginBottom: 16, + }, + errorText: { + fontSize: 18, + fontWeight: '600', + color: '#1F2937', + marginBottom: 8, + }, + errorDetail: { + fontSize: 14, + color: '#6B7280', + marginBottom: 24, + textAlign: 'center', + }, + retryButton: { + backgroundColor: '#3B82F6', + paddingHorizontal: 32, + paddingVertical: 12, + borderRadius: 8, + }, + retryButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + + // Header + header: { + backgroundColor: '#1E40AF', + paddingTop: 60, + paddingBottom: 24, + paddingHorizontal: 20, + }, + headerTop: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + headerTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#FFFFFF', + marginBottom: 4, + }, + headerSubtitle: { + fontSize: 14, + color: '#93C5FD', + }, + + // Summary + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 16, + gap: 12, + }, + summaryContainerPC: { + maxWidth: 800, + alignSelf: 'center', + width: '100%', + }, + summaryCard: { + flex: 1, + borderRadius: 12, + padding: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + summaryCardPrimary: { + backgroundColor: '#3B82F6', + }, + summaryCardSecondary: { + backgroundColor: '#10B981', + }, + summaryCardTertiary: { + backgroundColor: '#8B5CF6', + }, + summaryLabel: { + fontSize: 12, + color: 'rgba(255,255,255,0.8)', + marginBottom: 4, + }, + summaryValue: { + fontSize: 28, + fontWeight: 'bold', + color: '#FFFFFF', + }, + summaryUnit: { + fontSize: 12, + color: 'rgba(255,255,255,0.8)', + marginTop: 2, + }, + + // Cards + scrollView: { + flex: 1, + }, + cardContainer: { + padding: 16, + gap: 12, + }, + cardContainerPC: { + flexDirection: 'row', + flexWrap: 'wrap', + maxWidth: 1200, + alignSelf: 'center', + width: '100%', + gap: 16, + }, + card: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + cardPC: { + width: 'calc(33.333% - 11px)', + minWidth: 280, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 16, + }, + statusIcon: { + fontSize: 32, + marginRight: 12, + }, + cardTitleContainer: { + flex: 1, + }, + cardTitle: { + fontSize: 18, + fontWeight: '700', + color: '#1F2937', + marginBottom: 2, + }, + statusLabel: { + fontSize: 12, + fontWeight: '600', + }, + arrowIcon: { + fontSize: 24, + color: '#9CA3AF', + fontWeight: '300', + }, + cardBody: { + gap: 8, + }, + dataRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + dataLabel: { + fontSize: 14, + color: '#6B7280', + }, + dataValue: { + fontSize: 14, + fontWeight: '500', + color: '#374151', + }, + dataValuePrimary: { + fontSize: 20, + fontWeight: '700', + color: '#3B82F6', + }, + + // Progress Bar + progressContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 16, + gap: 8, + }, + progressBar: { + flex: 1, + height: 8, + backgroundColor: '#E5E7EB', + borderRadius: 4, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 4, + }, + progressText: { + fontSize: 12, + fontWeight: '600', + color: '#6B7280', + width: 40, + textAlign: 'right', + }, +}); diff --git a/app.json b/app.json new file mode 100644 index 0000000..5a77ecb --- /dev/null +++ b/app.json @@ -0,0 +1,26 @@ +{ + "expo": { + "name": "태양광 발전 현황", + "slug": "solorpower-dashboard", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "splash": { + "backgroundColor": "#1E40AF" + }, + "ios": { + "supportsTablet": true + }, + "android": { + "package": "com.solorpower.dashboard", + "adaptiveIcon": { + "backgroundColor": "#1E40AF" + } + }, + "web": { + "bundler": "metro", + "output": "single" + } + } +} \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..5ee9fc9 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/components/UploadModal.js b/components/UploadModal.js new file mode 100644 index 0000000..4b221a6 --- /dev/null +++ b/components/UploadModal.js @@ -0,0 +1,325 @@ +/** + * UploadModal.js - 과거 발전 데이터 엑셀 업로드 모달 + */ + +import React, { useState } from 'react'; +// Force Update Check +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, + ActivityIndicator, + Alert, +} from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; + +const API_URL = 'https://solorpower.dadot.net'; + +export default function UploadModal({ visible, onClose, plantId, onUploadSuccess }) { + const [uploading, setUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadType, setUploadType] = useState('daily'); // 'daily' | 'monthly' + + // 파일 선택 + const pickDocument = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx + 'application/vnd.ms-excel', // .xls + ], + copyToCacheDirectory: true, + }); + + if (!result.canceled && result.assets && result.assets.length > 0) { + setSelectedFile(result.assets[0]); + } + } catch (error) { + Alert.alert('오류', '파일 선택 중 오류가 발생했습니다.'); + } + }; + + // 업로드 실행 + const handleUpload = async () => { + if (!selectedFile) { + Alert.alert('알림', '먼저 파일을 선택해주세요.'); + return; + } + + if (!plantId) { + Alert.alert('알림', '발전소를 선택해주세요.'); + return; + } + + setUploading(true); + + try { + const formData = new FormData(); + + // 파일 추가 + formData.append('file', { + uri: selectedFile.uri, + name: selectedFile.name, + type: selectedFile.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + + // uploadType에 따라 엔드포인트 분기 + const endpoint = uploadType === 'daily' + ? `/plants/${plantId}/upload` + : `/plants/${plantId}/upload/monthly`; + + console.log(`🚀 Uploading ${uploadType} data`); + console.log(" URL:", `${API_URL}${endpoint}`); + + const response = await fetch(`${API_URL}${endpoint}`, { + method: 'POST', + body: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const result = await response.json(); + + if (response.ok) { + Alert.alert('성공', result.message || '업로드가 완료되었습니다.'); + setSelectedFile(null); + onUploadSuccess?.(); + onClose(); + } else { + Alert.alert('오류', result.detail || '업로드에 실패했습니다.'); + } + } catch (error) { + Alert.alert('오류', `업로드 중 오류가 발생했습니다: ${error.message}`); + } finally { + setUploading(false); + } + }; + + // 모달 닫기 + const handleClose = () => { + setSelectedFile(null); + setUploadType('daily'); + onClose(); + }; + + return ( + + + + {/* 헤더 */} + + 📂 과거 데이터 업로드 + + + + + + {/* 타입 선택 탭 */} + + setUploadType('daily')} + > + 일간 (Daily) + + setUploadType('monthly')} + > + 월간 (Monthly) + + + + {/* 안내 */} + + {uploadType === 'daily' + ? "필수 컬럼: date (날짜), generation (발전량) 또는 year, month, day, kwh" + : "필수 컬럼: year (연도), month (월), kwh (발전량)\n* 합계/평균 행 자동 제외" + } + + + {/* 발전소 ID 표시 */} + + 발전소 ID: + {plantId || '선택 안됨'} + + + {/* 파일 선택 */} + + + {selectedFile ? '📄 ' + selectedFile.name : '📁 엑셀 파일 선택 (.xlsx)'} + + + + {/* 선택된 파일 정보 */} + {selectedFile && ( + + + 크기: {(selectedFile.size / 1024).toFixed(1)} KB + + + )} + + {/* 업로드 버튼 */} + + {uploading ? ( + + ) : ( + + {uploadType === 'daily' ? '일간 데이터 업로드' : '월간 데이터 업로드'} + + )} + + + {/* 취소 버튼 */} + + 취소 + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + modalContainer: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 24, + width: '100%', + maxWidth: 400, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 16, + elevation: 8, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: '#1F2937', + }, + closeButton: { + padding: 4, + }, + closeButtonText: { + fontSize: 20, + color: '#6B7280', + }, + description: { + fontSize: 14, + color: '#6B7280', + marginBottom: 16, + lineHeight: 20, + }, + infoBox: { + flexDirection: 'row', + backgroundColor: '#F3F4F6', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + infoLabel: { + fontSize: 14, + color: '#6B7280', + marginRight: 8, + }, + infoValue: { + fontSize: 14, + fontWeight: '600', + color: '#1F2937', + }, + fileButton: { + backgroundColor: '#E5E7EB', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginBottom: 8, + borderWidth: 2, + borderColor: '#D1D5DB', + borderStyle: 'dashed', + }, + fileButtonText: { + fontSize: 16, + color: '#374151', + }, + fileInfo: { + alignItems: 'center', + marginBottom: 16, + }, + fileInfoText: { + fontSize: 12, + color: '#6B7280', + }, + uploadButton: { + backgroundColor: '#3B82F6', + padding: 16, + borderRadius: 8, + alignItems: 'center', + marginBottom: 8, + }, + uploadButtonDisabled: { + backgroundColor: '#9CA3AF', + }, + uploadButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, + cancelButton: { + padding: 12, + alignItems: 'center', + }, + cancelButtonText: { + fontSize: 14, + color: '#6B7280', + }, +}); diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..91ab321 --- /dev/null +++ b/eas.json @@ -0,0 +1,25 @@ +{ + "cli": { + "version": ">= 3.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "android": { + "buildType": "apk" + } + } + }, + "submit": { + "production": {} + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e37971f --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "solorpower-dashboard", + "version": "1.0.0", + "main": "expo/AppEntry.js", + "scripts": { + "start": "expo start", + "android": "expo run:android", + "ios": "expo run:ios", + "web": "expo start --web" + }, + "dependencies": { + "@expo/metro-runtime": "~4.0.1", + "@react-navigation/native": "^7.1.28", + "@react-navigation/native-stack": "^7.10.1", + "expo": "~52.0.0", + "expo-document-picker": "~13.0.3", + "expo-linear-gradient": "~14.0.2", + "expo-status-bar": "~2.0.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-native": "0.76.5", + "react-native-gifted-charts": "^1.4.70", + "react-native-safe-area-context": "^5.6.2", + "react-native-screens": "^4.20.0", + "react-native-svg": "^15.15.1", + "react-native-web": "~0.19.13" + }, + "devDependencies": { + "@babel/core": "^7.25.2" + }, + "private": true +} diff --git a/screens/PlantDetailScreen.js b/screens/PlantDetailScreen.js new file mode 100644 index 0000000..debf2e2 --- /dev/null +++ b/screens/PlantDetailScreen.js @@ -0,0 +1,791 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + ActivityIndicator, + ScrollView, + Alert, + useWindowDimensions, + Modal, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { BarChart } from 'react-native-gifted-charts'; +import * as DocumentPicker from 'expo-document-picker'; + +const API_BASE_URL = 'https://solorpower.dadot.net'; + +export default function PlantDetailScreen({ route, navigation }) { + const { plant } = route.params; + const [period, setPeriod] = useState('today'); + const [chartData, setChartData] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [todayData, setTodayData] = useState(null); + const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, label: '', value: 0 }); + const { width } = useWindowDimensions(); + + const [currentDate, setCurrentDate] = useState(new Date()); + + // 탭 변경 시 날짜 초기화 + useEffect(() => { + setCurrentDate(new Date()); + }, [period]); + + // 날짜 이동 + const moveDate = (direction) => { + const newDate = new Date(currentDate); + if (period === 'today') { + newDate.setDate(newDate.getDate() + direction); + } else if (period === 'day') { + // 일간 탭: '월' 단위 이동 + newDate.setMonth(newDate.getMonth() + direction); + } else if (period === 'month') { + // 월간 탭: '년' 단위 이동 + newDate.setFullYear(newDate.getFullYear() + direction); + } else if (period === 'year') { + // 연간 탭: '년' 단위 이동 (일단 1년씩) + newDate.setFullYear(newDate.getFullYear() + direction); + } + setCurrentDate(newDate); + }; + + // 통계 데이터 조회 + const fetchStats = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const y = currentDate.getFullYear(); + const m = currentDate.getMonth() + 1; + const d = currentDate.getDate(); + const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + + if (period === 'today') { + // 오늘 실시간 데이터는 이미 전달받은 plant.latest_log 사용 (상단 카드용) + // 만약 과거 날짜를 선택했다면 상단 카드는 숨기거나 해당 날짜의 요약으로 대체해야 함. + // 여기선 '오늘'인 경우에만 상단 카드를 보여주는 식으로 처리 가능. + + // 서버에서 시간별 데이터 조회 + const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats/today?date=${dateStr}`); + if (!response.ok) throw new Error(`HTTP Error: ${response.status}`); + const result = await response.json(); + const hourlyData = result.data || []; + + // 24시간 데이터 포맷팅 + const formattedData = []; + for (let i = 0; i <= 24; i++) { + if (i === 24) { + formattedData.push({ value: 0, label: '24시', frontColor: 'transparent' }); + continue; + } + + const item = hourlyData.find(d => d.hour === i) || {}; + // 현재 시간인지 확인 (오늘 날짜이고 현재 시간인 경우) + const isToday = new Date().toDateString() === currentDate.toDateString(); + const isCurrentHour = isToday && (i === new Date().getHours()); + const hasData = item.has_data || item.current_kw > 0; + + let color = '#E5E7EB'; + if (isCurrentHour) { + color = '#10B981'; + } else if (hasData) { + color = '#3B82F6'; + } + + formattedData.push({ + value: item.current_kw || 0, + label: i % 2 === 0 ? `${i}시` : '', + frontColor: color, + onPress: () => i < 24 && showTooltip(item.current_kw || 0, `${i}시`), + }); + } + setChartData(formattedData); + + // Today 탭이지만 과거 날짜인 경우 상단 요약 카드 데이터 갱신 필요 + // API 결과에 daily total이 포함되어 있다고 가정하거나, hourly sum을 해야 함. + // 현재 API(stats/today)는 today_kwh를 주지만, 이는 그 시간대까지의 누적임. + // 마지막 시간대의 today_kwh가 그날의 총 발전량. + // 편의상 차트 데이터만 갱신. + + } else { + // 기존 일간/월간/연간 조회 + // 쿼리 파라미터 추가 + let query = `period=${period}`; + if (period === 'day') { + // period=day -> year, month 필요 + query += `&year=${y}&month=${m}`; + } else if (period === 'month') { + // period=month -> year 필요 + query += `&year=${y}`; + } else if (period === 'year') { + // period=year -> year 필요 (기준 연도) + query += `&year=${y}`; + } + + const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/stats?${query}`); + if (!response.ok) throw new Error(`HTTP Error: ${response.status}`); + const result = await response.json(); + + const rawData = result.data || []; + const formattedData = []; + + if (period === 'day') { + // 선택된 달의 마지막 날 + const lastDay = new Date(y, m, 0).getDate(); + const dataMap = {}; + rawData.forEach(item => { dataMap[item.label] = item.value; }); + + for (let day = 1; day <= lastDay; day++) { + const ds = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const val = dataMap[ds] || 0; + const showLabel = (day === 1 || day % 5 === 0); + + formattedData.push({ + value: val, + label: showLabel ? `${day}` : '', + labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 }, + frontColor: val > 0 ? '#3B82F6' : '#E5E7EB', + }); + } + } else if (period === 'month') { + // 1월 ~ 12월 + const dataMap = {}; + rawData.forEach(item => { dataMap[item.label] = item.value; }); + + for (let month = 1; month <= 12; month++) { + const ms = `${y}-${String(month).padStart(2, '0')}`; + const val = dataMap[ms] || 0; + + formattedData.push({ + value: val, + label: `${month}월`, + labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -5 }, + frontColor: val > 0 ? '#3B82F6' : '#E5E7EB', + renderTooltip: renderTooltip, + }); + } + } else { + // year (기존 유지) + rawData.forEach(item => { + formattedData.push({ + value: item.value || 0, + label: formatLabel(item.label, period), + labelTextStyle: { fontSize: 10, color: '#6B7280', width: 40, textAlign: 'center', shiftX: -10 }, + frontColor: item.value > 0 ? '#3B82F6' : '#E5E7EB', + renderTooltip: renderTooltip, + }); + }); + } + + setChartData(formattedData); + } + } catch (err) { + setError(err.message); + setChartData([]); + } finally { + setLoading(false); + } + }, [plant.id, period, currentDate]); + + // 날짜 포맷팅 (YYYY년 MM월 DD일 등) + const formatDateDisplay = () => { + const y = currentDate.getFullYear(); + const m = currentDate.getMonth() + 1; + const d = currentDate.getDate(); + + if (period === 'today') return `${y}년 ${m}월 ${d}일`; + if (period === 'day') return `${y}년 ${m}월`; + if (period === 'month') return `${y}년`; + if (period === 'year') return `${y - 4}년 ~ ${y}년`; + return `${y}년 기준`; + }; + + // 기간 설명 (수정) + const getPeriodDescription = () => { + return formatDateDisplay(); + }; + + // 탭 렌더링 (아래 날짜 컨트롤 추가) + // ... renderTabs는 그대로 두고, 호출하는 곳에서 컨트롤 추가 + + // 날짜 컨트롤 렌더링 + const renderDateControl = () => ( + + moveDate(-1)} style={{ padding: 8 }}> + + + + {formatDateDisplay()} + + moveDate(1)} style={{ padding: 8 }}> + + + + ); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + // 툴팁 표시 + // 툴팁 렌더링 (커스텀) + const renderTooltip = (item, index) => { + return ( + + + {item.value.toFixed(1)}{period === 'today' ? 'kW' : 'kWh'} + + + ); + }; + + // 라벨 포맷팅 + const formatLabel = (label, period) => { + if (!label) return ''; + + if (period === 'day') { + const parts = label.split('-'); + return parts.length >= 3 ? `${parseInt(parts[2], 10)}` : label; + } else if (period === 'month') { + const parts = label.split('-'); + // 앞에 0 제거하고 '월' 추가 (예: 01 -> 1월) + return parts.length >= 2 ? `${parseInt(parts[1], 10)}월` : label; + } else { + return label.length === 4 ? `${label.slice(2)}년` : label; + } + }; + + // 총 발전량 계산 + const getTotalGeneration = () => { + return chartData.reduce((sum, item) => sum + (item.value || 0), 0); + }; + + // 엑셀 업로드 핸들러 + const handleUpload = async () => { + try { + const result = await DocumentPicker.getDocumentAsync({ + type: [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + ], + copyToCacheDirectory: true, + }); + + if (result.canceled) return; + + const file = result.assets[0]; + setUploading(true); + + const formData = new FormData(); + + if (file.file) { + // Web: 실제 File 객체 사용 + formData.append('file', file.file); + } else { + // Native: URI 객체 사용 + formData.append('file', { + uri: file.uri, + type: file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + name: file.name, + }); + } + + const response = await fetch(`${API_BASE_URL}/plants/${plant.id}/upload/monthly`, { + method: 'POST', + body: formData, + }); + + const responseData = await response.json(); + + if (!response.ok) throw new Error(responseData.detail || '업로드 실패'); + + Alert.alert( + '업로드 완료', + responseData.message || `${responseData.saved_count}건의 데이터가 저장되었습니다.`, + [{ text: '확인' }] + ); + + fetchStats(); + } catch (err) { + Alert.alert('업로드 실패', err.message, [{ text: '확인' }]); + } finally { + setUploading(false); + } + }; + + // 탭 렌더링 + const renderTabs = () => ( + + {[ + { key: 'today', label: '오늘' }, + { key: 'day', label: '일간' }, + { key: 'month', label: '월간' }, + { key: 'year', label: '연간' }, + ].map((tab) => ( + setPeriod(tab.key)} + > + + {tab.label} + + + ))} + + ); + + // 기간 설명 (수정 - 위에서 이미 정의됨, 중복 제거) + // const getPeriodDescription = ... (removed) + + const chartWidth = Math.min(width - 60, 600); + + // 막대 너비 및 간격 계산 + let barWidth = 10; + let spacing = 10; + + if (period === 'today') { + // 화면 꽉 차게 (25개 항목: 0~24시) - 스크롤 없애기 위함 + const availableWidth = chartWidth - 20; + const itemWidth = availableWidth / 25; + barWidth = Math.max(4, itemWidth * 0.6); // 60% 바 + spacing = Math.max(2, itemWidth * 0.4); // 40% 여백 + } else if (period === 'year') { + barWidth = 40; + spacing = 20; + } else if (period === 'month') { + barWidth = 24; + spacing = 12; + } else { // day + barWidth = 10; + spacing = 3; + } + + return ( + + {/* 헤더 */} + + navigation.goBack()}> + ← 뒤로 + + {plant.name} + + + + + {/* 날짜 컨트롤 */} + {renderDateControl()} + + {/* 탭 */} + {renderTabs()} + + {/* 오늘 실시간 현황 카드 (today 탭일 때만) */} + {period === 'today' && todayData && ( + + + ⚡ 실시간 발전 현황 + 갱신: {todayData.updatedAt} + + + + {todayData.currentKw.toFixed(1)} + 현재 출력 (kW) + + + + {todayData.todayKwh.toFixed(1)} + 금일 발전량 (kWh) + + + + )} + + {/* 차트 영역 */} + + + + 📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()}) + + 막대를 터치하면 상세 정보 + + + {loading ? ( + + + 데이터 로딩 중... + + ) : error ? ( + + ⚠️ 데이터를 불러올 수 없습니다 + {error} + + 다시 시도 + + + ) : chartData.length === 0 ? ( + + 📭 표시할 데이터가 없습니다 + + {period === 'today' ? '크롤러가 데이터를 수집하면 표시됩니다' : '엑셀 파일을 업로드해 주세요'} + + + ) : ( + + d.value || 0)) * 1.2 || 10} // 툴팁 공간 확보 (20%) + width={chartWidth} + height={220} + barWidth={barWidth} + spacing={spacing} + barBorderRadius={4} + frontColor="#3B82F6" + yAxisThickness={1} + xAxisThickness={1} + yAxisColor="#E5E7EB" + xAxisColor="#E5E7EB" + yAxisTextStyle={{ color: '#6B7280', fontSize: 10 }} + xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 9 }} + noOfSections={5} + hideRules={false} + rulesColor="#F3F4F6" + showValuesAsTopLabel={false} + isAnimated + yAxisSuffix={period === 'today' ? '' : ''} + renderTooltip={renderTooltip} + /> + + )} + + + {/* 통계 요약 (today 제외) */} + {period !== 'today' && ( + + 📈 통계 요약 + + + 총 발전량 + + {getTotalGeneration().toLocaleString(undefined, { maximumFractionDigits: 1 })} + + kWh + + + 데이터 수 + {chartData.length} + + + + 설비 용량 + {plant.capacity || '-'} + kW + + + + )} + + {/* 업로드 버튼 */} + + + {uploading ? ( + + ) : ( + 📂 과거 엑셀 데이터 업로드 + )} + + + * 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다 + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F3F4F6', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#1E40AF', + paddingHorizontal: 16, + paddingVertical: 16, + }, + backButton: { + paddingVertical: 8, + paddingHorizontal: 4, + }, + backButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '500', + }, + headerTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#FFFFFF', + flex: 1, + textAlign: 'center', + }, + headerSpacer: { + width: 60, + }, + content: { + flex: 1, + }, + contentContainer: { + padding: 16, + gap: 16, + }, + + // Tabs + tabContainer: { + flexDirection: 'row', + backgroundColor: '#FFFFFF', + borderRadius: 12, + padding: 4, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + tab: { + flex: 1, + paddingVertical: 12, + alignItems: 'center', + borderRadius: 8, + }, + tabActive: { + backgroundColor: '#3B82F6', + }, + tabText: { + fontSize: 13, + fontWeight: '600', + color: '#6B7280', + }, + tabTextActive: { + color: '#FFFFFF', + }, + + // Today Card + todayCard: { + backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + backgroundColor: '#1E40AF', + borderRadius: 16, + padding: 20, + shadowColor: '#1E40AF', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + todayHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + todayTitle: { + fontSize: 16, + fontWeight: '700', + color: '#FFFFFF', + }, + todayTime: { + fontSize: 12, + color: '#93C5FD', + }, + todayStats: { + flexDirection: 'row', + alignItems: 'center', + }, + todayStat: { + flex: 1, + alignItems: 'center', + }, + todayStatValue: { + fontSize: 36, + fontWeight: 'bold', + color: '#FFFFFF', + }, + todayStatLabel: { + fontSize: 12, + color: '#93C5FD', + marginTop: 4, + }, + todayDivider: { + width: 1, + height: 50, + backgroundColor: 'rgba(255,255,255,0.3)', + marginHorizontal: 16, + }, + + // Chart Section + chartSection: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + chartHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: '#1F2937', + }, + chartHint: { + fontSize: 11, + color: '#9CA3AF', + }, + chartContainer: { + alignItems: 'center', + paddingVertical: 10, + }, + loadingContainer: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 12, + color: '#6B7280', + fontSize: 14, + }, + errorContainer: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + fontSize: 16, + color: '#EF4444', + marginBottom: 8, + }, + errorDetail: { + fontSize: 12, + color: '#6B7280', + marginBottom: 16, + }, + retryButton: { + backgroundColor: '#3B82F6', + paddingHorizontal: 24, + paddingVertical: 10, + borderRadius: 8, + }, + retryButtonText: { + color: '#FFFFFF', + fontWeight: '600', + }, + emptyContainer: { + height: 200, + justifyContent: 'center', + alignItems: 'center', + }, + emptyText: { + fontSize: 18, + color: '#6B7280', + marginBottom: 8, + }, + emptySubtext: { + fontSize: 14, + color: '#9CA3AF', + }, + + // Summary Section + summarySection: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + summaryCards: { + flexDirection: 'row', + gap: 12, + marginTop: 12, + }, + summaryCard: { + flex: 1, + backgroundColor: '#F0F9FF', + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + summaryLabel: { + fontSize: 12, + color: '#6B7280', + marginBottom: 4, + }, + summaryValue: { + fontSize: 24, + fontWeight: 'bold', + color: '#1E40AF', + }, + summaryUnit: { + fontSize: 12, + color: '#6B7280', + marginTop: 2, + }, + + // Upload Section + uploadSection: { + alignItems: 'center', + paddingVertical: 8, + }, + uploadButton: { + backgroundColor: '#10B981', + paddingHorizontal: 32, + paddingVertical: 16, + borderRadius: 12, + width: '100%', + alignItems: 'center', + shadowColor: '#10B981', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + uploadButtonDisabled: { + backgroundColor: '#9CA3AF', + }, + uploadButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + }, + uploadHint: { + marginTop: 12, + fontSize: 12, + color: '#9CA3AF', + }, +});