ERP-node/frontend/components/dashboard/widgets/WeatherWidget.tsx

538 lines
20 KiB
TypeScript
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.

'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-warning" />;
case 'clouds':
return <Cloud className="h-12 w-12 text-muted-foreground" />;
case 'rain':
case 'drizzle':
return <CloudRain className="h-12 w-12 text-primary" />;
case 'snow':
return <CloudSnow className="h-12 w-12 text-primary/70" />;
default:
return <Cloud className="h-12 w-12 text-muted-foreground" />;
}
};
// 로딩 상태
if (loading && !weather) {
return (
<div className="flex h-full items-center justify-center bg-background rounded-lg border p-6">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<p className="text-sm font-semibold text-foreground mb-1"> API ...</p>
<p className="text-xs text-muted-foreground"> </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 bg-background">
<Cloud className="h-12 w-12 text-muted-foreground mb-2" />
<div className="text-center mb-3">
<p className="text-sm font-semibold text-foreground mb-1">
{isTestMode ? '⚠️ 테스트 모드' : '❌ 연결 실패'}
</p>
<p className="text-xs text-foreground">
{error || '날씨 정보를 불러올 수 없습니다.'}
</p>
{isTestMode && (
<p className="text-xs text-warning 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-background 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-foreground 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-foreground hover:bg-muted/80 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-muted-foreground 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-foreground 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-primary/10 text-primary'
: 'text-foreground hover:bg-muted'
)}
>
<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-muted/80 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-warning`} />;
case 'clouds':
return <Cloud className={`${iconClass} text-muted-foreground`} />;
case 'rain':
case 'drizzle':
return <CloudRain className={`${iconClass} text-primary`} />;
case 'snow':
return <CloudSnow className={`${iconClass} text-primary/70`} />;
default:
return <Cloud className={`${iconClass} text-muted-foreground`} />;
}
})()}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold text-foreground leading-tight truncate">
{weather.temperature}°C
</div>
<p className="text-xs text-muted-foreground capitalize leading-tight truncate">
{weather.weatherDescription}
</p>
</div>
</div>
</div>
{/* 기온 - 선택 가능 */}
{selectedItems.includes('temperature') && (
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
<Sun className="h-3.5 w-3.5 text-warning flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground leading-tight truncate"></p>
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{weather.temperature}°C
</p>
</div>
</div>
)}
{/* 체감 온도 */}
{selectedItems.includes('feelsLike') && (
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
<Wind className="h-3.5 w-3.5 text-primary flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground leading-tight truncate"></p>
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{weather.feelsLike}°C
</p>
</div>
</div>
)}
{/* 습도 */}
{selectedItems.includes('humidity') && (
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
<Droplets className="h-3.5 w-3.5 text-primary flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground leading-tight truncate"></p>
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{weather.humidity}%
</p>
</div>
</div>
)}
{/* 풍속 */}
{selectedItems.includes('windSpeed') && (
<div className="flex items-center gap-1.5 bg-muted/80 rounded-lg p-3">
<Wind className="h-3.5 w-3.5 text-success flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground leading-tight truncate"></p>
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{weather.windSpeed} m/s
</p>
</div>
</div>
)}
{/* 기압 */}
{selectedItems.includes('pressure') && (
<div className="flex items-center gap-1.5 bg-muted/80 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-muted-foreground leading-tight truncate"></p>
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{weather.pressure} hPa
</p>
</div>
</div>
)}
</div>
</div>
);
}