542 lines
20 KiB
TypeScript
542 lines
20 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* 날씨 위젯 컴포넌트
|
||
* - 실시간 날씨 정보를 표시
|
||
*/
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { getWeather, WeatherData } from '@/lib/api/openApi';
|
||
import {
|
||
Cloud,
|
||
CloudRain,
|
||
Sun,
|
||
CloudSnow,
|
||
Wind,
|
||
Droplets,
|
||
Gauge,
|
||
RefreshCw,
|
||
Check,
|
||
ChevronsUpDown,
|
||
Settings,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||
import { cn } from '@/lib/utils';
|
||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||
|
||
interface WeatherWidgetProps {
|
||
element?: DashboardElement;
|
||
city?: string;
|
||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||
}
|
||
|
||
export default function WeatherWidget({
|
||
element,
|
||
city = '서울',
|
||
refreshInterval = 600000,
|
||
}: WeatherWidgetProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [selectedCity, setSelectedCity] = useState(city);
|
||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||
|
||
// 표시할 날씨 정보 선택
|
||
const [selectedItems, setSelectedItems] = useState<string[]>([
|
||
'temperature',
|
||
'feelsLike',
|
||
'humidity',
|
||
'windSpeed',
|
||
'pressure',
|
||
]);
|
||
|
||
// 날씨 항목 정의
|
||
const weatherItems = [
|
||
{ id: 'temperature', label: '기온', icon: Sun },
|
||
{ id: 'feelsLike', label: '체감온도', icon: Sun },
|
||
{ id: 'humidity', label: '습도', icon: Droplets },
|
||
{ id: 'windSpeed', label: '풍속', icon: Wind },
|
||
{ id: 'pressure', label: '기압', icon: Gauge },
|
||
];
|
||
|
||
// 항목 토글
|
||
const toggleItem = (itemId: string) => {
|
||
setSelectedItems((prev) =>
|
||
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
|
||
);
|
||
};
|
||
|
||
// 도시 목록 (전국 시/군/구 단위)
|
||
const cities = [
|
||
// 서울특별시 (25개 구)
|
||
{ value: '서울', label: '서울' },
|
||
{ value: '종로구', label: '서울 종로구' },
|
||
{ value: '중구', label: '서울 중구' },
|
||
{ value: '용산구', label: '서울 용산구' },
|
||
{ value: '성동구', label: '서울 성동구' },
|
||
{ value: '광진구', label: '서울 광진구' },
|
||
{ value: '동대문구', label: '서울 동대문구' },
|
||
{ value: '중랑구', label: '서울 중랑구' },
|
||
{ value: '성북구', label: '서울 성북구' },
|
||
{ value: '강북구', label: '서울 강북구' },
|
||
{ value: '도봉구', label: '서울 도봉구' },
|
||
{ value: '노원구', label: '서울 노원구' },
|
||
{ value: '은평구', label: '서울 은평구' },
|
||
{ value: '서대문구', label: '서울 서대문구' },
|
||
{ value: '마포구', label: '서울 마포구' },
|
||
{ value: '양천구', label: '서울 양천구' },
|
||
{ value: '강서구', label: '서울 강서구' },
|
||
{ value: '구로구', label: '서울 구로구' },
|
||
{ value: '금천구', label: '서울 금천구' },
|
||
{ value: '영등포구', label: '서울 영등포구' },
|
||
{ value: '동작구', label: '서울 동작구' },
|
||
{ value: '관악구', label: '서울 관악구' },
|
||
{ value: '서초구', label: '서울 서초구' },
|
||
{ value: '강남구', label: '서울 강남구' },
|
||
{ value: '송파구', label: '서울 송파구' },
|
||
{ value: '강동구', label: '서울 강동구' },
|
||
|
||
// 부산광역시
|
||
{ value: '부산', label: '부산' },
|
||
{ value: '해운대구', label: '부산 해운대구' },
|
||
{ value: '부산진구', label: '부산 부산진구' },
|
||
{ value: '동래구', label: '부산 동래구' },
|
||
{ value: '사하구', label: '부산 사하구' },
|
||
{ value: '금정구', label: '부산 금정구' },
|
||
{ value: '사상구', label: '부산 사상구' },
|
||
|
||
// 인천광역시
|
||
{ value: '인천', label: '인천' },
|
||
{ value: '부평구', label: '인천 부평구' },
|
||
{ value: '계양구', label: '인천 계양구' },
|
||
{ value: '남동구', label: '인천 남동구' },
|
||
|
||
// 대구광역시
|
||
{ value: '대구', label: '대구' },
|
||
{ value: '수성구', label: '대구 수성구' },
|
||
{ value: '달서구', label: '대구 달서구' },
|
||
|
||
// 광주광역시
|
||
{ value: '광주', label: '광주' },
|
||
{ value: '광산구', label: '광주 광산구' },
|
||
|
||
// 대전광역시
|
||
{ value: '대전', label: '대전' },
|
||
{ value: '유성구', label: '대전 유성구' },
|
||
|
||
// 울산광역시
|
||
{ value: '울산', label: '울산' },
|
||
|
||
// 세종특별자치시
|
||
{ value: '세종', label: '세종' },
|
||
|
||
// 경기도 (주요 도시)
|
||
{ value: '수원', label: '수원' },
|
||
{ value: '성남', label: '성남' },
|
||
{ value: '고양', label: '고양' },
|
||
{ value: '용인', label: '용인' },
|
||
{ value: '부천', label: '부천' },
|
||
{ value: '안산', label: '안산' },
|
||
{ value: '안양', label: '안양' },
|
||
{ value: '남양주', label: '남양주' },
|
||
{ value: '화성', label: '화성' },
|
||
{ value: '평택', label: '평택' },
|
||
{ value: '의정부', label: '의정부' },
|
||
{ value: '시흥', label: '시흥' },
|
||
{ value: '파주', label: '파주' },
|
||
{ value: '김포', label: '김포' },
|
||
{ value: '광명', label: '광명' },
|
||
|
||
// 강원도
|
||
{ value: '춘천', label: '춘천' },
|
||
{ value: '원주', label: '원주' },
|
||
{ value: '강릉', label: '강릉' },
|
||
{ value: '속초', label: '속초' },
|
||
{ value: '동해', label: '동해' },
|
||
{ value: '태백', label: '태백' },
|
||
{ value: '삼척', label: '삼척' },
|
||
|
||
// 충청북도
|
||
{ value: '청주', label: '청주' },
|
||
{ value: '충주', label: '충주' },
|
||
{ value: '제천', label: '제천' },
|
||
|
||
// 충청남도
|
||
{ value: '천안', label: '천안' },
|
||
{ value: '공주', label: '공주' },
|
||
{ value: '보령', label: '보령' },
|
||
{ value: '아산', label: '아산' },
|
||
{ value: '서산', label: '서산' },
|
||
{ value: '논산', label: '논산' },
|
||
{ value: '당진', label: '당진' },
|
||
|
||
// 전라북도
|
||
{ value: '전주', label: '전주' },
|
||
{ value: '군산', label: '군산' },
|
||
{ value: '익산', label: '익산' },
|
||
{ value: '정읍', label: '정읍' },
|
||
{ value: '남원', label: '남원' },
|
||
{ value: '김제', label: '김제' },
|
||
|
||
// 전라남도
|
||
{ value: '목포', label: '목포' },
|
||
{ value: '여수', label: '여수' },
|
||
{ value: '순천', label: '순천' },
|
||
{ value: '나주', label: '나주' },
|
||
{ value: '광양', label: '광양' },
|
||
|
||
// 경상북도
|
||
{ value: '포항', label: '포항' },
|
||
{ value: '경주', label: '경주' },
|
||
{ value: '김천', label: '김천' },
|
||
{ value: '안동', label: '안동' },
|
||
{ value: '구미', label: '구미' },
|
||
{ value: '영주', label: '영주' },
|
||
{ value: '영천', label: '영천' },
|
||
{ value: '상주', label: '상주' },
|
||
{ value: '문경', label: '문경' },
|
||
{ value: '경산', label: '경산' },
|
||
{ value: '울릉도', label: '울릉도' },
|
||
|
||
// 경상남도
|
||
{ value: '창원', label: '창원' },
|
||
{ value: '진주', label: '진주' },
|
||
{ value: '통영', label: '통영' },
|
||
{ value: '사천', label: '사천' },
|
||
{ value: '김해', label: '김해' },
|
||
{ value: '밀양', label: '밀양' },
|
||
{ value: '거제', label: '거제' },
|
||
{ value: '양산', label: '양산' },
|
||
|
||
// 제주특별자치도
|
||
{ value: '제주', label: '제주' },
|
||
{ value: '서귀포', label: '서귀포' },
|
||
];
|
||
|
||
// 날씨 정보 가져오기
|
||
const fetchWeather = async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const data = await getWeather(selectedCity, 'metric', 'kr');
|
||
setWeather(data);
|
||
setLastUpdated(new Date());
|
||
} catch (err: any) {
|
||
console.error('날씨 조회 실패:', err);
|
||
|
||
// 에러 메시지 추출
|
||
let errorMessage = '날씨 정보를 가져오는 중 오류가 발생했습니다.';
|
||
|
||
if (err.response?.status === 503) {
|
||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||
} else if (err.response?.status === 401) {
|
||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||
} else if (err.response?.status === 404) {
|
||
errorMessage = `도시를 찾을 수 없습니다: ${city}`;
|
||
} else if (err.response?.data?.message) {
|
||
errorMessage = err.response.data.message;
|
||
}
|
||
|
||
setError(errorMessage);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 초기 로딩 및 자동 새로고침
|
||
useEffect(() => {
|
||
fetchWeather();
|
||
const interval = setInterval(fetchWeather, refreshInterval);
|
||
return () => clearInterval(interval);
|
||
}, [selectedCity, refreshInterval]);
|
||
|
||
// 도시 변경 핸들러
|
||
const handleCityChange = (newCity: string) => {
|
||
setSelectedCity(newCity);
|
||
};
|
||
|
||
// 날씨 아이콘 선택
|
||
const getWeatherIcon = (weatherMain: string) => {
|
||
switch (weatherMain.toLowerCase()) {
|
||
case 'clear':
|
||
return <Sun className="h-12 w-12 text-yellow-500" />;
|
||
case 'clouds':
|
||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||
case 'rain':
|
||
case 'drizzle':
|
||
return <CloudRain className="h-12 w-12 text-blue-500" />;
|
||
case 'snow':
|
||
return <CloudSnow className="h-12 w-12 text-blue-300" />;
|
||
default:
|
||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||
}
|
||
};
|
||
|
||
// 로딩 상태
|
||
if (loading && !weather) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||
<div className="flex flex-col items-center gap-3">
|
||
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
|
||
<div className="text-center">
|
||
<p className="text-sm font-semibold text-gray-800 mb-1">실제 기상청 API 연결 중...</p>
|
||
<p className="text-xs text-gray-500">실시간 관측 데이터를 가져오고 있습니다</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 에러 상태
|
||
if (error || !weather) {
|
||
const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
|
||
return (
|
||
<div className={`flex h-full flex-col items-center justify-center rounded-lg border p-6 ${
|
||
isTestMode
|
||
? 'bg-gradient-to-br from-yellow-50 to-orange-50'
|
||
: 'bg-gradient-to-br from-red-50 to-orange-50'
|
||
}`}>
|
||
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
|
||
<div className="text-center mb-3">
|
||
<p className="text-sm font-semibold text-gray-800 mb-1">
|
||
{isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
|
||
</p>
|
||
<p className="text-xs text-gray-600">
|
||
{error || '날씨 정보를 불러올 수 없습니다.'}
|
||
</p>
|
||
{isTestMode && (
|
||
<p className="text-xs text-yellow-700 mt-2">
|
||
임시 데이터가 표시됩니다
|
||
</p>
|
||
)}
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={fetchWeather}
|
||
className="gap-1"
|
||
>
|
||
<RefreshCw className="h-3 w-3" />
|
||
다시 시도
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-4">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-1">🌤️ {element?.customTitle || "날씨"}</h3>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
role="combobox"
|
||
aria-expanded={open}
|
||
className="justify-between text-sm text-gray-600 hover:bg-white/50 h-auto py-0.5 px-2"
|
||
>
|
||
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
|
||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[200px] p-0" align="start">
|
||
<Command>
|
||
<CommandInput placeholder="도시 검색..." />
|
||
<CommandList>
|
||
<CommandEmpty>도시를 찾을 수 없습니다.</CommandEmpty>
|
||
<CommandGroup>
|
||
{cities.map((city) => (
|
||
<CommandItem
|
||
key={city.value}
|
||
value={city.value}
|
||
onSelect={(currentValue) => {
|
||
handleCityChange(currentValue === selectedCity ? selectedCity : currentValue);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
<Check
|
||
className={cn(
|
||
'mr-2 h-4 w-4',
|
||
selectedCity === city.value ? 'opacity-100' : 'opacity-0'
|
||
)}
|
||
/>
|
||
{city.label}
|
||
</CommandItem>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
<p className="text-xs text-gray-500 pl-2">
|
||
{lastUpdated
|
||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}`
|
||
: ''}
|
||
</p>
|
||
</div>
|
||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-[200px] p-3" align="end">
|
||
<div className="space-y-2">
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-3">표시 항목</h4>
|
||
{weatherItems.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
onClick={() => toggleItem(item.id)}
|
||
className={cn(
|
||
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors',
|
||
selectedItems.includes(item.id)
|
||
? 'bg-blue-50 text-blue-700'
|
||
: 'text-gray-600 hover:bg-gray-50'
|
||
)}
|
||
>
|
||
<Check
|
||
className={cn(
|
||
'h-3.5 w-3.5',
|
||
selectedItems.includes(item.id) ? 'opacity-100' : 'opacity-0'
|
||
)}
|
||
/>
|
||
<Icon className="h-3.5 w-3.5" />
|
||
<span>{item.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={fetchWeather}
|
||
disabled={loading}
|
||
className="h-8 w-8 p-0"
|
||
>
|
||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 반응형 그리드 레이아웃 - 자동 조정 */}
|
||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
||
{/* 날씨 아이콘 및 온도 */}
|
||
<div className="bg-white/50 rounded-lg p-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="flex-shrink-0">
|
||
{(() => {
|
||
const iconClass = "h-5 w-5";
|
||
switch (weather.weatherMain.toLowerCase()) {
|
||
case 'clear':
|
||
return <Sun className={`${iconClass} text-yellow-500`} />;
|
||
case 'clouds':
|
||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||
case 'rain':
|
||
case 'drizzle':
|
||
return <CloudRain className={`${iconClass} text-blue-500`} />;
|
||
case 'snow':
|
||
return <CloudSnow className={`${iconClass} text-blue-300`} />;
|
||
default:
|
||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||
}
|
||
})()}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-bold text-gray-900 leading-tight truncate">
|
||
{weather.temperature}°C
|
||
</div>
|
||
<p className="text-xs text-gray-400 capitalize leading-tight truncate">
|
||
{weather.weatherDescription}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 기온 - 선택 가능 */}
|
||
{selectedItems.includes('temperature') && (
|
||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||
<Sun className="h-3.5 w-3.5 text-orange-500 flex-shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-gray-400 leading-tight truncate">기온</p>
|
||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||
{weather.temperature}°C
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 체감 온도 */}
|
||
{selectedItems.includes('feelsLike') && (
|
||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||
<Wind className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-gray-400 leading-tight truncate">체감온도</p>
|
||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||
{weather.feelsLike}°C
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 습도 */}
|
||
{selectedItems.includes('humidity') && (
|
||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||
<Droplets className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-gray-400 leading-tight truncate">습도</p>
|
||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||
{weather.humidity}%
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 풍속 */}
|
||
{selectedItems.includes('windSpeed') && (
|
||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||
<Wind className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-gray-400 leading-tight truncate">풍속</p>
|
||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||
{weather.windSpeed} m/s
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 기압 */}
|
||
{selectedItems.includes('pressure') && (
|
||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||
<Gauge className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-gray-400 leading-tight truncate">기압</p>
|
||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||
{weather.pressure} hPa
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|