501 lines
13 KiB
JavaScript
501 lines
13 KiB
JavaScript
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',
|
||
},
|
||
});
|