solorpower_app/screens/PlantDetailScreen.js

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