solorpower_app/App.js

501 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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',
},
});