Initial commit for SolarPower App
This commit is contained in:
commit
3420bee2bf
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
500
App.js
Normal file
500
App.js
Normal file
|
|
@ -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 (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>데이터를 불러오는 중...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Text style={styles.errorIcon}>⚠️</Text>
|
||||||
|
<Text style={styles.errorText}>오류가 발생했습니다</Text>
|
||||||
|
<Text style={styles.errorDetail}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={fetchData}>
|
||||||
|
<Text style={styles.retryButtonText}>다시 시도</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
|
||||||
|
{/* 상단 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerTop}>
|
||||||
|
<Text style={styles.headerTitle}>☀️ 태양광 발전 현황</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.headerSubtitle}>실시간 모니터링 대시보드</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 요약 카드 */}
|
||||||
|
<View style={[styles.summaryContainer, isPC && styles.summaryContainerPC]}>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardPrimary]}>
|
||||||
|
<Text style={styles.summaryLabel}>총 출력</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.totalOutput.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kW</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardSecondary]}>
|
||||||
|
<Text style={styles.summaryLabel}>가동률</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.operatingRate.toFixed(0)}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.summaryCard, styles.summaryCardTertiary]}>
|
||||||
|
<Text style={styles.summaryLabel}>가동 현황</Text>
|
||||||
|
<Text style={styles.summaryValue}>{summary.activePlants}/{summary.totalPlants}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>발전소</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 발전소 목록 */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.cardContainer,
|
||||||
|
isPC && styles.cardContainerPC,
|
||||||
|
]}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={plant.id || index}
|
||||||
|
style={[styles.card, isPC && styles.cardPC]}
|
||||||
|
onPress={() => handlePlantPress(plant)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.statusIcon}>{statusInfo.icon}</Text>
|
||||||
|
<View style={styles.cardTitleContainer}>
|
||||||
|
<Text style={styles.cardTitle}>{plant.name}</Text>
|
||||||
|
<Text style={[styles.statusLabel, { color: statusInfo.color }]}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.arrowIcon}>›</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.cardBody}>
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>현재 출력</Text>
|
||||||
|
<Text style={styles.dataValuePrimary}>{currentKw.toFixed(1)} kW</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>금일 발전</Text>
|
||||||
|
<Text style={styles.dataValue}>{dailyKwh.toFixed(1)} kWh</Text>
|
||||||
|
</View>
|
||||||
|
{plant.capacity && (
|
||||||
|
<View style={styles.dataRow}>
|
||||||
|
<Text style={styles.dataLabel}>설비 용량</Text>
|
||||||
|
<Text style={styles.dataValue}>{plant.capacity} kW</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
{plant.capacity && (
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<View style={styles.progressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{
|
||||||
|
width: `${Math.min((currentKw / plant.capacity) * 100, 100)}%`,
|
||||||
|
backgroundColor: statusInfo.color,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.progressText}>
|
||||||
|
{((currentKw / plant.capacity) * 100).toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 앱
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<NavigationContainer>
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="Dashboard" component={DashboardScreen} />
|
||||||
|
<Stack.Screen name="PlantDetail" component={PlantDetailScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
</NavigationContainer>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
26
app.json
Normal file
26
app.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
babel.config.js
Normal file
6
babel.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
325
components/UploadModal.js
Normal file
325
components/UploadModal.js
Normal file
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.modalContainer}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>📂 과거 데이터 업로드</Text>
|
||||||
|
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
||||||
|
<Text style={styles.closeButtonText}>✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 타입 선택 탭 */}
|
||||||
|
<View style={{ flexDirection: 'row', marginBottom: 16, backgroundColor: '#F3F4F6', borderRadius: 8, padding: 4 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: uploadType === 'daily' ? '#FFFFFF' : 'transparent',
|
||||||
|
shadowColor: uploadType === 'daily' ? '#000' : 'transparent',
|
||||||
|
shadowOpacity: uploadType === 'daily' ? 0.1 : 0,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: uploadType === 'daily' ? 2 : 0,
|
||||||
|
}}
|
||||||
|
onPress={() => setUploadType('daily')}
|
||||||
|
>
|
||||||
|
<Text style={{ fontWeight: uploadType === 'daily' ? '600' : '400', color: '#374151' }}>일간 (Daily)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: uploadType === 'monthly' ? '#FFFFFF' : 'transparent',
|
||||||
|
shadowColor: uploadType === 'monthly' ? '#000' : 'transparent',
|
||||||
|
shadowOpacity: uploadType === 'monthly' ? 0.1 : 0,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: uploadType === 'monthly' ? 2 : 0,
|
||||||
|
}}
|
||||||
|
onPress={() => setUploadType('monthly')}
|
||||||
|
>
|
||||||
|
<Text style={{ fontWeight: uploadType === 'monthly' ? '600' : '400', color: '#374151' }}>월간 (Monthly)</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 안내 */}
|
||||||
|
<Text style={styles.description}>
|
||||||
|
{uploadType === 'daily'
|
||||||
|
? "필수 컬럼: date (날짜), generation (발전량) 또는 year, month, day, kwh"
|
||||||
|
: "필수 컬럼: year (연도), month (월), kwh (발전량)\n* 합계/평균 행 자동 제외"
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 발전소 ID 표시 */}
|
||||||
|
<View style={styles.infoBox}>
|
||||||
|
<Text style={styles.infoLabel}>발전소 ID:</Text>
|
||||||
|
<Text style={styles.infoValue}>{plantId || '선택 안됨'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 파일 선택 */}
|
||||||
|
<TouchableOpacity style={styles.fileButton} onPress={pickDocument}>
|
||||||
|
<Text style={styles.fileButtonText}>
|
||||||
|
{selectedFile ? '📄 ' + selectedFile.name : '📁 엑셀 파일 선택 (.xlsx)'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 선택된 파일 정보 */}
|
||||||
|
{selectedFile && (
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text style={styles.fileInfoText}>
|
||||||
|
크기: {(selectedFile.size / 1024).toFixed(1)} KB
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.uploadButton,
|
||||||
|
(!selectedFile || uploading) && styles.uploadButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={!selectedFile || uploading}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.uploadButtonText}>
|
||||||
|
{uploadType === 'daily' ? '일간 데이터 업로드' : '월간 데이터 업로드'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 취소 버튼 */}
|
||||||
|
<TouchableOpacity style={styles.cancelButton} onPress={handleClose}>
|
||||||
|
<Text style={styles.cancelButtonText}>취소</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
25
eas.json
Normal file
25
eas.json
Normal file
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
791
screens/PlantDetailScreen.js
Normal file
791
screens/PlantDetailScreen.js
Normal file
|
|
@ -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 = () => (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginVertical: 12, gap: 16 }}>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(-1)} style={{ padding: 8 }}>
|
||||||
|
<Text style={{ fontSize: 20, color: '#4B5563' }}>◀</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: 'bold', color: '#1F2937' }}>
|
||||||
|
{formatDateDisplay()}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => moveDate(1)} style={{ padding: 8 }}>
|
||||||
|
<Text style={{ fontSize: 20, color: '#4B5563' }}>▶</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
// 툴팁 표시
|
||||||
|
// 툴팁 렌더링 (커스텀)
|
||||||
|
const renderTooltip = (item, index) => {
|
||||||
|
return (
|
||||||
|
<View style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: -6,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 10 }}>
|
||||||
|
{item.value.toFixed(1)}{period === 'today' ? 'kW' : 'kWh'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 라벨 포맷팅
|
||||||
|
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 = () => (
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
{[
|
||||||
|
{ key: 'today', label: '오늘' },
|
||||||
|
{ key: 'day', label: '일간' },
|
||||||
|
{ key: 'month', label: '월간' },
|
||||||
|
{ key: 'year', label: '연간' },
|
||||||
|
].map((tab) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={tab.key}
|
||||||
|
style={[styles.tab, period === tab.key && styles.tabActive]}
|
||||||
|
onPress={() => setPeriod(tab.key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabText, period === tab.key && styles.tabTextActive]}>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기간 설명 (수정 - 위에서 이미 정의됨, 중복 제거)
|
||||||
|
// 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 (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backButtonText}>← 뒤로</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>{plant.name}</Text>
|
||||||
|
<View style={styles.headerSpacer} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} contentContainerStyle={styles.contentContainer}>
|
||||||
|
{/* 날짜 컨트롤 */}
|
||||||
|
{renderDateControl()}
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
{renderTabs()}
|
||||||
|
|
||||||
|
{/* 오늘 실시간 현황 카드 (today 탭일 때만) */}
|
||||||
|
{period === 'today' && todayData && (
|
||||||
|
<View style={styles.todayCard}>
|
||||||
|
<View style={styles.todayHeader}>
|
||||||
|
<Text style={styles.todayTitle}>⚡ 실시간 발전 현황</Text>
|
||||||
|
<Text style={styles.todayTime}>갱신: {todayData.updatedAt}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.todayStats}>
|
||||||
|
<View style={styles.todayStat}>
|
||||||
|
<Text style={styles.todayStatValue}>{todayData.currentKw.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.todayStatLabel}>현재 출력 (kW)</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.todayDivider} />
|
||||||
|
<View style={styles.todayStat}>
|
||||||
|
<Text style={styles.todayStatValue}>{todayData.todayKwh.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.todayStatLabel}>금일 발전량 (kWh)</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<View style={styles.chartSection}>
|
||||||
|
<View style={styles.chartHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()})
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.chartHint}>막대를 터치하면 상세 정보</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>데이터 로딩 중...</Text>
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>⚠️ 데이터를 불러올 수 없습니다</Text>
|
||||||
|
<Text style={styles.errorDetail}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
|
||||||
|
<Text style={styles.retryButtonText}>다시 시도</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>📭 표시할 데이터가 없습니다</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
{period === 'today' ? '크롤러가 데이터를 수집하면 표시됩니다' : '엑셀 파일을 업로드해 주세요'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.chartContainer}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
maxValue={Math.max(...chartData.map(d => 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}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 통계 요약 (today 제외) */}
|
||||||
|
{period !== 'today' && (
|
||||||
|
<View style={styles.summarySection}>
|
||||||
|
<Text style={styles.sectionTitle}>📈 통계 요약</Text>
|
||||||
|
<View style={styles.summaryCards}>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>총 발전량</Text>
|
||||||
|
<Text style={styles.summaryValue}>
|
||||||
|
{getTotalGeneration().toLocaleString(undefined, { maximumFractionDigits: 1 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kWh</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>데이터 수</Text>
|
||||||
|
<Text style={styles.summaryValue}>{chartData.length}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>건</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryLabel}>설비 용량</Text>
|
||||||
|
<Text style={styles.summaryValue}>{plant.capacity || '-'}</Text>
|
||||||
|
<Text style={styles.summaryUnit}>kW</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 업로드 버튼 */}
|
||||||
|
<View style={styles.uploadSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
|
||||||
|
onPress={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.uploadButtonText}>📂 과거 엑셀 데이터 업로드</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.uploadHint}>
|
||||||
|
* 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user