solorpower/app/screens/PlantDetailScreen.js
haneulai f2bb131884
Some checks are pending
CI / Crawler (Python ${{ matrix.python-version }}) (3.10) (push) Waiting to run
CI / Crawler (Python ${{ matrix.python-version }}) (3.11) (push) Waiting to run
CI / API (Python 3.11) (push) Waiting to run
CI / Database migration (push) Waiting to run
CI / App web build (Node 20) (push) Waiting to run
refactor: complete repository structure cleanup
2026-08-07 15:30:44 +09:00

499 lines
16 KiB
JavaScript
Raw 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.

import React, { useState, useEffect } 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';
import StatsPeriodControls from '../components/StatsPeriodControls';
import UploadModal from '../components/UploadModal';
import usePlantStats from '../hooks/usePlantStats';
export default function PlantDetailScreen({ route, navigation }) {
const { plant } = route.params;
const [period, setPeriod] = useState('today');
const [uploadVisible, setUploadVisible] = useState(false);
const { width } = useWindowDimensions();
const [currentDate, setCurrentDate] = useState(new Date());
const { chartData, loading, error, reload: fetchStats, todayData } = usePlantStats(
plant,
period,
currentDate,
);
// 탭 변경 시 날짜 초기화
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);
};
// 날짜 포맷팅 (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 renderTooltip = (item) => {
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 getTotalGeneration = () => {
return chartData.reduce((sum, item) => sum + (item.value || 0), 0);
};
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}>
<StatsPeriodControls
dateLabel={formatDateDisplay()}
onMoveDate={moveDate}
onPeriodChange={setPeriod}
period={period}
/>
{/* 오늘 실시간 현황 카드 (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' ? '시간대별 출력' : '발전량 추이'} ({formatDateDisplay()})
</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}
onPress={() => setUploadVisible(true)}
>
<Text style={styles.uploadButtonText}>📂 과거 엑셀 데이터 업로드</Text>
</TouchableOpacity>
<Text style={styles.uploadHint}>
* 일간 또는 월간 형식을 선택할 있습니다
</Text>
</View>
</ScrollView>
<UploadModal
visible={uploadVisible}
onClose={() => setUploadVisible(false)}
plantId={plant.id}
onUploadSuccess={fetchStats}
/>
</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,
},
// 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,
},
uploadButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
uploadHint: {
marginTop: 12,
fontSize: 12,
color: '#9CA3AF',
},
});