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 = () => ( moveDate(-1)} style={{ padding: 8 }}> {formatDateDisplay()} moveDate(1)} style={{ padding: 8 }}> ); useEffect(() => { fetchStats(); }, [fetchStats]); // 툴팁 표시 // 툴팁 렌더링 (커스텀) const renderTooltip = (item, index) => { return ( {item.value.toFixed(1)}{period === 'today' ? 'kW' : 'kWh'} ); }; // 라벨 포맷팅 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 = () => ( {[ { key: 'today', label: '오늘' }, { key: 'day', label: '일간' }, { key: 'month', label: '월간' }, { key: 'year', label: '연간' }, ].map((tab) => ( setPeriod(tab.key)} > {tab.label} ))} ); // 기간 설명 (수정 - 위에서 이미 정의됨, 중복 제거) // 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 ( {/* 헤더 */} navigation.goBack()}> ← 뒤로 {plant.name} {/* 날짜 컨트롤 */} {renderDateControl()} {/* 탭 */} {renderTabs()} {/* 오늘 실시간 현황 카드 (today 탭일 때만) */} {period === 'today' && todayData && ( ⚡ 실시간 발전 현황 갱신: {todayData.updatedAt} {todayData.currentKw.toFixed(1)} 현재 출력 (kW) {todayData.todayKwh.toFixed(1)} 금일 발전량 (kWh) )} {/* 차트 영역 */} 📊 {period === 'today' ? '시간대별 출력' : '발전량 추이'} ({getPeriodDescription()}) 막대를 터치하면 상세 정보 {loading ? ( 데이터 로딩 중... ) : error ? ( ⚠️ 데이터를 불러올 수 없습니다 {error} 다시 시도 ) : chartData.length === 0 ? ( 📭 표시할 데이터가 없습니다 {period === 'today' ? '크롤러가 데이터를 수집하면 표시됩니다' : '엑셀 파일을 업로드해 주세요'} ) : ( 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} /> )} {/* 통계 요약 (today 제외) */} {period !== 'today' && ( 📈 통계 요약 총 발전량 {getTotalGeneration().toLocaleString(undefined, { maximumFractionDigits: 1 })} kWh 데이터 수 {chartData.length} 설비 용량 {plant.capacity || '-'} kW )} {/* 업로드 버튼 */} {uploading ? ( ) : ( 📂 과거 엑셀 데이터 업로드 )} * 엑셀 파일에 'date', 'generation' 컬럼이 필요합니다 ); } 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', }, });