diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..06e699f
--- /dev/null
+++ b/app/.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/App.js b/app/App.js
new file mode 100644
index 0000000..34e3c6b
--- /dev/null
+++ b/app/App.js
@@ -0,0 +1,579 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ ScrollView,
+ TouchableOpacity,
+ ActivityIndicator,
+ useWindowDimensions,
+ RefreshControl,
+ Switch,
+} 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';
+import ComparisonScreen from './screens/ComparisonScreen';
+
+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 handleToggleAlert = async (plant, newValue) => {
+ try {
+ setData((prev) => {
+ if (!prev) return prev;
+ const newData = { ...prev };
+ newData.data = newData.data.map(p =>
+ p.id === plant.id ? { ...p, alerts_enabled: newValue } : p
+ );
+ return newData;
+ });
+
+ const response = await fetch(`https://solorpower.dadot.net/plants/${plant.company_id || 1}/${plant.id}/alerts`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ alerts_enabled: newValue }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update alert settings');
+ }
+ } catch (err) {
+ console.error(err);
+ setData((prev) => {
+ if (!prev) return prev;
+ const newData = { ...prev };
+ newData.data = newData.data.map(p =>
+ p.id === plant.id ? { ...p, alerts_enabled: !newValue } : p
+ );
+ return newData;
+ });
+ alert('알림 설정 변경에 실패했습니다.');
+ }
+ };
+
+ 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 (
+
+
+
+ {/* 상단 헤더 */}
+
+
+ ☀️ 태양광 발전 현황
+ navigation.navigate('Comparison')}
+ >
+ 📊 전체 비교
+
+
+ 실시간 모니터링 대시보드
+
+
+ {/* 요약 카드 */}
+
+
+ 총 출력
+ {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}
+
+
+
+
+ {plant.alerts_enabled !== false ? '알림 ON' : '알림 OFF'}
+
+ handleToggleAlert(plant, value)}
+ trackColor={{ false: '#D1D5DB', true: '#3B82F6' }}
+ thumbColor={'#FFFFFF'}
+ />
+
+
+
+
+
+ 현재 출력
+ {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',
+ },
+ alertToggleContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ alertToggleLabel: {
+ fontSize: 12,
+ color: '#6B7280',
+ fontWeight: '500',
+ },
+ 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',
+ },
+ compareButton: {
+ backgroundColor: 'rgba(255,255,255,0.2)',
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.4)',
+ marginLeft: 12,
+ },
+ compareButtonText: {
+ color: '#FFFFFF',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+});
diff --git a/app/app.json b/app/app.json
new file mode 100644
index 0000000..5a77ecb
--- /dev/null
+++ b/app/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/app/babel.config.js b/app/babel.config.js
new file mode 100644
index 0000000..5ee9fc9
--- /dev/null
+++ b/app/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ };
+};
diff --git a/app/components/UploadModal.js b/app/components/UploadModal.js
new file mode 100644
index 0000000..4b221a6
--- /dev/null
+++ b/app/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/app/eas.json b/app/eas.json
new file mode 100644
index 0000000..91ab321
--- /dev/null
+++ b/app/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/app/package.json b/app/package.json
new file mode 100644
index 0000000..e37971f
--- /dev/null
+++ b/app/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/app/screens/ComparisonScreen.js b/app/screens/ComparisonScreen.js
new file mode 100644
index 0000000..d6b09bc
--- /dev/null
+++ b/app/screens/ComparisonScreen.js
@@ -0,0 +1,432 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ StyleSheet,
+ View,
+ Text,
+ TouchableOpacity,
+ ActivityIndicator,
+ ScrollView,
+ useWindowDimensions,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { BarChart } from 'react-native-gifted-charts';
+
+const API_BASE_URL = 'https://solorpower.dadot.net';
+
+export default function ComparisonScreen({ navigation }) {
+ const [period, setPeriod] = useState('day');
+ const [statsData, setStatsData] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [currentDate, setCurrentDate] = useState(new Date());
+ const { width } = useWindowDimensions();
+
+ useEffect(() => {
+ setCurrentDate(new Date());
+ }, [period]);
+
+ const moveDate = (direction) => {
+ const newDate = new Date(currentDate);
+ if (period === 'day') {
+ newDate.setDate(newDate.getDate() + direction);
+ } else if (period === 'month') {
+ newDate.setMonth(newDate.getMonth() + direction);
+ } else if (period === 'year') {
+ newDate.setFullYear(newDate.getFullYear() + direction);
+ }
+ setCurrentDate(newDate);
+ };
+
+ const formatDateDisplay = () => {
+ const y = currentDate.getFullYear();
+ const m = currentDate.getMonth() + 1;
+ const d = currentDate.getDate();
+
+ if (period === 'day') return `${y}년 ${m}월 ${d}일`;
+ if (period === 'month') return `${y}년 ${m}월`;
+ if (period === 'year') return `${y}년`;
+ return '';
+ };
+
+ 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')}`;
+
+ let query = `period=${period}`;
+ if (period === 'day') {
+ query += `&date=${dateStr}`;
+ } else if (period === 'month') {
+ query += `&year=${y}&month=${m}`;
+ } else if (period === 'year') {
+ query += `&year=${y}`;
+ }
+
+ const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`);
+ if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
+
+ const result = await response.json();
+ setStatsData(result.data || []);
+
+ } catch (err) {
+ setError(err.message);
+ setStatsData([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [period, currentDate]);
+
+ useEffect(() => {
+ fetchStats();
+ }, [fetchStats]);
+
+ // Chart Data Preparation
+ const chartData = statsData.map(item => {
+ let label = item.plant_name;
+ const match = label.match(/(\d+호기)/);
+ if (match) {
+ label = match[1];
+ } else {
+ label = label.replace(/태양과바람|발전소/g, '').trim();
+ }
+
+ return {
+ value: item.generation,
+ label: label,
+ frontColor: '#3B82F6',
+ topLabelComponent: () => (
+
+ {item.generation.toFixed(0)}
+
+ ),
+ };
+ });
+
+ const renderTabs = () => (
+
+ {['day', 'month', 'year'].map((p) => (
+ setPeriod(p)}
+ >
+
+ {p === 'day' ? '일간' : p === 'month' ? '월간' : '연간'}
+
+
+ ))}
+
+ );
+
+ const renderDateControl = () => (
+
+ moveDate(-1)} style={styles.arrowButton}>
+ ◀
+
+ {formatDateDisplay()}
+ moveDate(1)} style={styles.arrowButton}>
+ ▶
+
+
+ );
+
+ const renderTable = () => (
+
+
+ 발전소
+ 발전량
+ 용량
+ 시간
+
+ {statsData.map((item, index) => (
+
+ {item.plant_name}
+ {item.generation.toFixed(1)}
+ {item.capacity}
+
+ {item.generation_hours.toFixed(2)}
+
+
+ ))}
+ {statsData.length === 0 && (
+
+ 데이터가 없습니다
+
+ )}
+
+ );
+
+ const chartWidth = width - 40;
+
+ return (
+
+
+ navigation.goBack()}>
+ ← 뒤로
+
+ 전체 발전소 비교
+
+
+
+
+ {renderDateControl()}
+ {renderTabs()}
+
+ {loading ? (
+
+
+ 데이터 불러오는 중...
+
+ ) : error ? (
+
+ 데이터 조회 실패
+ {error}
+
+ 다시 시도
+
+
+ ) : (
+ <>
+
+ 📊 발전량 비교 (kWh)
+
+ {statsData.length > 0 ? (
+
+ ) : (
+ 표시할 데이터가 없습니다.
+ )}
+
+
+
+
+
+ 📋 상세 현황
+ 시간 = 발전량 / 설비용량 {period === 'year' ? '/ 365' : ''}
+
+ {renderTable()}
+
+ >
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#F3F4F6',
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: '#1E40AF',
+ padding: 16,
+ },
+ backButton: {
+ padding: 8,
+ },
+ backButtonText: {
+ color: '#FFFFFF',
+ fontSize: 16,
+ fontWeight: '500',
+ },
+ headerTitle: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ color: '#FFFFFF',
+ },
+ content: {
+ flex: 1,
+ },
+ scrollContent: {
+ padding: 16,
+ paddingBottom: 40,
+ },
+ dateControl: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 16,
+ gap: 16,
+ },
+ arrowButton: {
+ padding: 8,
+ },
+ arrowText: {
+ fontSize: 20,
+ color: '#4B5563',
+ },
+ dateText: {
+ fontSize: 18,
+ fontWeight: 'bold',
+ color: '#1F2937',
+ },
+ tabContainer: {
+ flexDirection: 'row',
+ backgroundColor: '#FFFFFF',
+ borderRadius: 12,
+ padding: 4,
+ marginBottom: 16,
+ shadowColor: '#000',
+ shadowOpacity: 0.05,
+ shadowRadius: 4,
+ elevation: 2,
+ },
+ tab: {
+ flex: 1,
+ paddingVertical: 10,
+ alignItems: 'center',
+ borderRadius: 8,
+ },
+ tabActive: {
+ backgroundColor: '#3B82F6',
+ },
+ tabText: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: '#6B7280',
+ },
+ tabTextActive: {
+ color: '#FFFFFF',
+ },
+ loadingContainer: {
+ height: 300,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loadingText: {
+ marginTop: 12,
+ color: '#6B7280',
+ },
+ errorContainer: {
+ height: 300,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ errorText: {
+ fontSize: 16,
+ color: '#EF4444',
+ marginBottom: 8,
+ },
+ errorDetail: {
+ fontSize: 12,
+ color: '#6B7280',
+ marginBottom: 16,
+ },
+ retryButton: {
+ backgroundColor: '#3B82F6',
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ borderRadius: 8,
+ },
+ retryButtonText: {
+ color: '#FFFFFF',
+ },
+ chartSection: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 16,
+ padding: 16,
+ marginBottom: 16,
+ shadowColor: '#000',
+ shadowOpacity: 0.05,
+ shadowRadius: 8,
+ elevation: 2,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ color: '#1F2937',
+ marginBottom: 12,
+ },
+ chartContainer: {
+ alignItems: 'center',
+ paddingVertical: 10,
+ },
+ noDataText: {
+ color: '#9CA3AF',
+ marginVertical: 20,
+ },
+ tableSection: {
+ backgroundColor: '#FFFFFF',
+ borderRadius: 16,
+ padding: 16,
+ shadowColor: '#000',
+ shadowOpacity: 0.05,
+ shadowRadius: 8,
+ elevation: 2,
+ },
+ tableHeaderRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ unitText: {
+ fontSize: 11,
+ color: '#6B7280',
+ },
+ table: {
+ borderWidth: 1,
+ borderColor: '#E5E7EB',
+ borderRadius: 8,
+ overflow: 'hidden',
+ },
+ tableHeader: {
+ flexDirection: 'row',
+ backgroundColor: '#F3F4F6',
+ paddingVertical: 10,
+ paddingHorizontal: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: '#E5E7EB',
+ },
+ columnHeader: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ color: '#4B5563',
+ textAlign: 'center',
+ },
+ tableRow: {
+ flexDirection: 'row',
+ paddingVertical: 12,
+ paddingHorizontal: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: '#E5E7EB',
+ },
+ tableRowAlt: {
+ backgroundColor: '#F9FAFB',
+ },
+ cell: {
+ fontSize: 12,
+ color: '#1F2937',
+ textAlign: 'center',
+ },
+ emptyRow: {
+ padding: 20,
+ alignItems: 'center',
+ },
+ emptyText: {
+ color: '#9CA3AF',
+ fontSize: 14,
+ },
+});
diff --git a/app/screens/PlantDetailScreen.js b/app/screens/PlantDetailScreen.js
new file mode 100644
index 0000000..debf2e2
--- /dev/null
+++ b/app/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',
+ },
+});