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