Compare commits

...

2 Commits

Author SHA1 Message Date
leeheejin d819a7c77e Merge branch 'main' into lhj - 위젯 UI 개선사항 유지 2025-10-16 13:50:23 +09:00
leeheejin f183b4a727 위젯헤더 작업중 2025-10-16 13:48:48 +09:00
19 changed files with 530 additions and 198 deletions

View File

@ -231,6 +231,14 @@ app.listen(PORT, HOST, async () => {
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 데이터베이스 마이그레이션 실행
try {
const { runMigrations } = await import('./database/migrations');
await runMigrations();
} catch (error) {
logger.error(`❌ 데이터베이스 마이그레이션 실패:`, error);
}
// 배치 스케줄러 초기화
try {
await BatchSchedulerService.initialize();

View File

@ -17,7 +17,7 @@ export class OpenApiProxyController {
console.log(`🌤️ 날씨 조회 요청: ${city}`);
// 기상청 API Hub 키 확인
// 기상청 API Hub 키 확인 (우선 사용)
const apiKey = process.env.KMA_API_KEY;
// API 키가 없으면 테스트 모드로 실시간 날씨 제공
@ -93,34 +93,18 @@ export class OpenApiProxyController {
data: weatherData,
});
} catch (error: unknown) {
console.error('❌ 날씨 조회 실패:', error);
console.error('❌ 기상청 날씨 조회 실패:', error);
// API 호출 실패 시 자동으로 테스트 모드로 전환
if (axios.isAxiosError(error)) {
const status = error.response?.status;
// 모든 오류 → 테스트 데이터 반환
console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
} else {
// 예상치 못한 오류 → 테스트 데이터 반환
console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
}
// 기상청 실패 시 실시간 근사 데이터 반환
console.log('⚠️ 기상청 API 실패. 실시간 근사 데이터를 반환합니다.');
const { city = '서울' } = req.query;
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
});
}
}
@ -250,6 +234,60 @@ export class OpenApiProxyController {
}
}
/**
* OpenWeatherMap API ( )
*/
async function fetchOpenWeatherMapData(city: string, apiKey: string): Promise<any> {
// 한글 도시명 → 영문 변환
const cityMap: Record<string, string> = {
'서울': 'Seoul',
'부산': 'Busan',
'인천': 'Incheon',
'대구': 'Daegu',
'광주': 'Gwangju',
'대전': 'Daejeon',
'울산': 'Ulsan',
'세종': 'Sejong',
'제주': 'Jeju',
'수원': 'Suwon',
'고양': 'Goyang',
'용인': 'Yongin',
'창원': 'Changwon',
};
const englishCity = cityMap[city] || city;
console.log(`📡 OpenWeatherMap API 호출: ${englishCity}`);
const url = 'https://api.openweathermap.org/data/2.5/weather';
const response = await axios.get(url, {
params: {
q: `${englishCity},KR`,
appid: apiKey,
units: 'metric', // 섭씨
lang: 'kr', // 한국어
},
timeout: 10000,
});
const data = response.data;
return {
city: city, // 원래 요청한 도시명 유지
country: 'KR',
temperature: Math.round(data.main.temp),
feelsLike: Math.round(data.main.feels_like),
humidity: data.main.humidity,
pressure: data.main.pressure,
weatherMain: data.weather[0].main,
weatherDescription: data.weather[0].description,
weatherIcon: data.weather[0].icon,
windSpeed: Math.round(data.wind.speed * 10) / 10,
clouds: data.clouds.all,
timestamp: new Date().toISOString(),
};
}
/**
* ( // )
*/
@ -606,13 +644,19 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st
}
// 요청한 관측소(stnId)의 데이터 찾기
const targetLine = lines.find((line: string) => {
let targetLine = lines.find((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
});
// 요청한 관측소 데이터가 없으면 첫 번째 관측소 데이터 사용
if (!targetLine) {
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
console.log(`⚠️ 관측소 ${regionCode.stnId} (${regionCode.name}) 데이터 없음. 다른 관측소 데이터 사용`);
targetLine = lines[0]; // 첫 번째 관측소 데이터 사용
if (!targetLine) {
throw new Error(`날씨 데이터가 없습니다.`);
}
}
// 데이터 라인 파싱 (공백으로 구분)

View File

@ -0,0 +1,27 @@
/**
*
*
*/
import { PostgreSQLService } from './PostgreSQLService';
export async function runMigrations(): Promise<void> {
console.log('🔄 데이터베이스 마이그레이션 시작...');
try {
// dashboard_elements에 custom_title, show_header 컬럼 추가
await PostgreSQLService.query(`
ALTER TABLE dashboard_elements
ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255),
ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT TRUE
`);
console.log('✅ dashboard_elements 마이그레이션 완료');
} catch (error) {
console.error('❌ 마이그레이션 오류:', error);
throw error;
}
console.log('✅ 모든 마이그레이션 완료');
}

View File

@ -16,6 +16,21 @@ export interface Alert {
timestamp: string;
}
export interface WeatherData {
city: string;
country: string;
temperature: number;
feelsLike: number;
humidity: number;
pressure: number;
weatherMain: string;
weatherDescription: string;
weatherIcon: string;
windSpeed: number;
clouds: number;
timestamp: string;
}
export class RiskAlertService {
/**
* ( API - API)
@ -361,6 +376,134 @@ export class RiskAlertService {
return this.generateDummyRoadworkAlerts();
}
/**
*
*/
async getCurrentWeather(city: string = '서울'): Promise<WeatherData> {
try {
const apiKey = process.env.KMA_API_KEY;
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 없습니다. 근사 데이터를 반환합니다.');
return this.generateRealisticWeatherData(city);
}
// 도시명 → 관측소 코드 매핑
const regionCode = this.getKMARegionCode(city);
if (!regionCode) {
console.log(`⚠️ 지원하지 않는 지역: ${city}. 근사 데이터를 반환합니다.`);
return this.generateRealisticWeatherData(city);
}
// 기상청 API Hub - 지상관측시간자료
const now = new Date();
const minute = now.getMinutes();
let targetTime = new Date(now);
// 데이터가 업데이트되지 않았으면 이전 시간으로
if (minute < 10) {
targetTime = new Date(now.getTime() - 60 * 60 * 1000);
}
const year = targetTime.getFullYear();
const month = String(targetTime.getMonth() + 1).padStart(2, '0');
const day = String(targetTime.getDate()).padStart(2, '0');
const hour = String(targetTime.getHours()).padStart(2, '0');
const tm = `${year}${month}${day}${hour}00`;
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
console.log(`📡 기상청 실시간 날씨 조회: ${regionCode.name} (${tm})`);
const response = await axios.get(url, {
params: {
tm: tm,
stn: 0,
authKey: apiKey,
help: 0,
disp: 1,
},
timeout: 10000,
responseType: 'arraybuffer',
});
// EUC-KR 디코딩
const iconv = require('iconv-lite');
const responseText = iconv.decode(Buffer.from(response.data), 'EUC-KR');
// 데이터 파싱
const lines = responseText.split('\n').filter((line: string) => line.trim() && !line.startsWith('#'));
if (lines.length === 0) {
throw new Error('날씨 데이터가 없습니다.');
}
// 요청한 관측소 데이터 찾기 (없으면 첫 번째 관측소 사용)
let targetLine = lines.find((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId;
});
if (!targetLine) {
console.log(`⚠️ 관측소 ${regionCode.stnId} 데이터 없음. 다른 관측소 데이터 사용`);
targetLine = lines[0];
}
const values = targetLine.trim().split(/\s+/);
// 데이터 추출
const temperature = parseFloat(values[11]) || 0; // TA: 기온
const humidity = parseFloat(values[13]) || 0; // HM: 습도
const pressure = parseFloat(values[7]) || 1013; // PA: 기압
const windSpeed = parseFloat(values[3]) || 0; // WS: 풍속
const rainfall = parseFloat(values[15]) || 0; // RN: 강수량
console.log(`✅ 기상청 실시간 날씨: ${regionCode.name} ${temperature}°C, 습도 ${humidity}%`);
// 날씨 상태 추정
let weatherMain = 'Clear';
let weatherDescription = '맑음';
let weatherIcon = '01d';
let clouds = 10;
if (rainfall > 0) {
weatherMain = 'Rain';
weatherDescription = rainfall >= 10 ? '비 (강수)' : '비';
weatherIcon = rainfall >= 10 ? '10d' : '09d';
clouds = 100;
} else if (humidity > 80) {
weatherMain = 'Clouds';
weatherDescription = '흐림';
weatherIcon = '04d';
clouds = 90;
} else if (humidity > 60) {
weatherMain = 'Clouds';
weatherDescription = '구름 많음';
weatherIcon = '03d';
clouds = 60;
}
return {
city: regionCode.name,
country: 'KR',
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.round(humidity),
pressure: Math.round(pressure),
weatherMain,
weatherDescription,
weatherIcon,
windSpeed: Math.round(windSpeed * 10) / 10,
clouds,
timestamp: new Date().toISOString(),
};
} catch (error: any) {
console.error('❌ 기상청 실시간 날씨 조회 실패:', error.message);
return this.generateRealisticWeatherData(city);
}
}
/**
* ()
*/
@ -385,6 +528,69 @@ export class RiskAlertService {
}
}
/**
*
*/
private getKMARegionCode(city: string): { name: string; stnId: string } | null {
const regions: Record<string, { name: string; stnId: string }> = {
'Seoul': { name: '서울', stnId: '108' },
'Busan': { name: '부산', stnId: '159' },
'Incheon': { name: '인천', stnId: '112' },
'서울': { name: '서울', stnId: '108' },
'부산': { name: '부산', stnId: '159' },
'인천': { name: '인천', stnId: '112' },
'대구': { name: '대구', stnId: '143' },
'광주': { name: '광주', stnId: '156' },
'대전': { name: '대전', stnId: '133' },
'울산': { name: '울산', stnId: '152' },
'세종': { name: '세종', stnId: '239' },
'제주': { name: '제주', stnId: '184' },
};
return regions[city] || null;
}
/**
*
*/
private generateRealisticWeatherData(cityName: string): WeatherData {
const now = new Date();
const hour = now.getHours();
const month = now.getMonth() + 1;
// 시간대별 기온 변화
let baseTemp = 15;
if (hour >= 6 && hour < 9) baseTemp = 12;
else if (hour >= 9 && hour < 12) baseTemp = 18;
else if (hour >= 12 && hour < 15) baseTemp = 22;
else if (hour >= 15 && hour < 18) baseTemp = 20;
else if (hour >= 18 && hour < 21) baseTemp = 16;
else baseTemp = 13;
// 계절별 보정
if (month >= 12 || month <= 2) baseTemp -= 8;
else if (month >= 3 && month <= 5) baseTemp += 2;
else if (month >= 6 && month <= 8) baseTemp += 8;
else baseTemp += 3;
const tempVariation = Math.floor(Math.random() * 5) - 2;
const temperature = baseTemp + tempVariation;
return {
city: cityName,
country: 'KR',
temperature: Math.round(temperature),
feelsLike: Math.round(temperature - 2),
humidity: Math.floor(Math.random() * 30) + 50,
pressure: Math.floor(Math.random() * 15) + 1008,
weatherMain: 'Clear',
weatherDescription: '맑음',
weatherIcon: '01d',
windSpeed: Math.random() * 4 + 1,
clouds: 10,
timestamp: now.toISOString(),
};
}
/**
* (YYYYMMDDHHmm -> ISO)
*/

View File

@ -15,6 +15,8 @@ export interface DashboardElement {
height: number;
};
title: string;
customTitle?: string; // 사용자 지정 제목
showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
content?: string;
dataSource?: {
type: "api" | "database" | "static";

View File

@ -46,6 +46,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
try {
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
console.log("📊 대시보드 데이터 로드됨:", dashboardData);
setDashboard(dashboardData);
} catch (apiError) {
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
@ -156,7 +157,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
{/* 대시보드 뷰어 */}
<div className="h-[calc(100vh-120px)]">
<DashboardViewer elements={dashboard.elements} dashboardId={dashboard.id} />
<DashboardViewer key={`dashboard-${dashboard.id}-${dashboard.elements.length}`} elements={dashboard.elements} dashboardId={dashboard.id} />
</div>
</div>
);

View File

@ -453,34 +453,34 @@ export function CanvasElement({
}}
onMouseDown={handleMouseDown}
>
{/* 헤더 */}
{/* 헤더 (편집 모드에서는 항상 표시) */}
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-bold text-gray-800">{element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{onConfigure &&
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 삭제 버튼 */}
<button
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={handleRemove}
title="삭제"
>
×
</button>
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{onConfigure &&
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 삭제 버튼 */}
<button
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={handleRemove}
title="삭제"
>
×
</button>
</div>
</div>
</div>
{/* 내용 */}
<div className="relative h-[calc(100%-45px)]">

View File

@ -251,6 +251,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 요소 업데이트
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
console.log("🔧 updateElement 호출:", id, updates);
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
}, []);
@ -287,9 +288,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 요소 설정 저장
const saveElementConfig = useCallback(
(updatedElement: DashboardElement) => {
console.log("💾 saveElementConfig 호출:", updatedElement.id, updatedElement.customTitle);
updateElement(updatedElement.id, updatedElement);
closeConfigModal();
},
[updateElement],
[updateElement, closeConfigModal],
);
// 리스트 위젯 설정 저장 (Partial 업데이트)
@ -320,6 +323,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
position: el.position,
size: el.size,
title: el.title,
customTitle: el.customTitle, // customTitle을 별도로 저장
showHeader: el.showHeader, // 헤더 표시 여부도 저장
content: el.content,
dataSource: el.dataSource,
chartConfig: el.chartConfig,
@ -467,36 +472,36 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === "chart") {
switch (subtype) {
case "bar":
return "📊 바 차트";
return "바 차트";
case "horizontal-bar":
return "📊 수평 바 차트";
return "수평 바 차트";
case "pie":
return "🥧 원형 차트";
return "원형 차트";
case "line":
return "📈 꺾은선 차트";
return "꺾은선 차트";
default:
return "📊 차트";
return "차트";
}
} else if (type === "widget") {
switch (subtype) {
case "exchange":
return "💱 환율 위젯";
return "환율 위젯";
case "weather":
return "☁️ 날씨 위젯";
return "날씨 위젯";
case "clock":
return "시계 위젯";
return "시계 위젯";
case "calculator":
return "🧮 계산기 위젯";
return "계산기 위젯";
case "vehicle-map":
return "🚚 차량 위치 지도";
return "차량 위치 지도";
case "calendar":
return "📅 달력 위젯";
return "달력 위젯";
case "driver-management":
return "🚚 기사 관리 위젯";
return "기사 관리 위젯";
case "list":
return "📋 리스트 위젯";
return "리스트 위젯";
default:
return "🔧 위젯";
return "위젯";
}
}
return "요소";

View File

@ -32,6 +32,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || "");
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
const isSimpleWidget =
@ -58,6 +59,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
setQueryResult(null);
setCurrentStep(1);
setCustomTitle(element.customTitle || "");
setShowHeader(element.showHeader !== false);
}
}, [isOpen, element]);
@ -122,13 +124,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
dataSource,
chartConfig,
customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined
showHeader, // 헤더 표시 여부
};
console.log(" 저장할 element:", updatedElement);
onSave(updatedElement);
onClose();
}, [element, dataSource, chartConfig, customTitle, onSave, onClose]);
}, [element, dataSource, chartConfig, customTitle, showHeader, onSave, onClose]);
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
@ -141,6 +144,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
return null;
}
// 환율, 날씨, 계산기, 리스크/알림, To-Do, 문서관리, 예약요청알림 위젯은 제목만 변경 가능한 간단한 모달 표시
const isSimpleConfigWidget =
element.type === "widget" &&
(element.subtype === "exchange" || element.subtype === "weather" || element.subtype === "calculator" ||
element.subtype === "risk-alert" || element.subtype === "todo" || element.subtype === "document" ||
element.subtype === "booking-alert");
// 저장 가능 여부 확인
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
const isApiSource = dataSource.type === "api";
@ -153,7 +163,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// customTitle이 변경되었는지 확인
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
const canSave = isTitleChanged || // 제목만 변경해도 저장 가능
const canSave =
isSimpleConfigWidget || // 간단한 설정 위젯은 항상 저장 가능
isTitleChanged || // 제목만 변경해도 저장 가능
(isSimpleWidget
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
currentStep === 2 && queryResult && queryResult.rows.length > 0
@ -190,11 +202,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">{element.title} </h2>
<p className="mt-1 text-sm text-gray-500">
{isSimpleWidget
? "데이터 소스를 설정하세요"
: currentStep === 1
? "데이터 소스를 선택하세요"
: "쿼리를 실행하고 차트를 설정하세요"}
{isSimpleConfigWidget
? "위젯 제목과 표시 옵션을 설정하세요"
: isSimpleWidget
? "데이터 소스를 설정하세요"
: currentStep === 1
? "데이터 소스를 선택하세요"
: "쿼리를 실행하고 차트를 설정하세요"}
</p>
</div>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
@ -218,10 +232,26 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
💡 (: "maintenance_schedules 목록")
</p>
</div>
{/* 헤더 표시 여부 */}
<div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showHeader}
onChange={(e) => setShowHeader(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="font-medium text-gray-700"> </span>
</label>
<p className="mt-1 ml-6 text-xs text-gray-500">
</p>
</div>
</div>
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
{!isSimpleWidget && (
{!isSimpleWidget && !isSimpleConfigWidget && (
<div className="border-b bg-gray-50 px-6 py-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-700">
@ -233,7 +263,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{/* 단계별 내용 */}
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
{/* 간단한 설정 위젯 (환율, 날씨, 계산기)는 제목만 표시 */}
{isSimpleConfigWidget ? (
<div className="text-center py-8">
<p className="text-gray-600"> .</p>
</div>
) : currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}

View File

@ -55,6 +55,7 @@ export interface DashboardElement {
size: Size;
title: string;
customTitle?: string; // 사용자 정의 제목 (옵션)
showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
content: string;
dataSource?: ChartDataSource; // 데이터 소스 설정
chartConfig?: ChartConfig; // 차트 설정

View File

@ -25,18 +25,7 @@ const DocumentWidget = dynamic(() => import("./widgets/DocumentWidget"), { ssr:
const BookingAlertWidget = dynamic(() => import("./widgets/BookingAlertWidget"), { ssr: false });
const MaintenanceWidget = dynamic(() => import("./widgets/MaintenanceWidget"), { ssr: false });
const CalculatorWidget = dynamic(() => import("./widgets/CalculatorWidget"), { ssr: false });
const CalendarWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/CalendarWidget").then((mod) => ({ default: mod.CalendarWidget })),
{ ssr: false },
);
const ClockWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })),
{ ssr: false },
);
const ListWidget = dynamic(
() => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })),
{ ssr: false },
);
const CalendarWidget = dynamic(() => import("@/components/admin/dashboard/widgets/CalendarWidget").then(mod => ({ default: mod.CalendarWidget })), { ssr: false });
/**
* - DashboardSidebar의 subtype
@ -45,7 +34,7 @@ const ListWidget = dynamic(
function renderWidget(element: DashboardElement) {
switch (element.subtype) {
// 차트는 ChartRenderer에서 처리됨 (이 함수 호출 안됨)
// === 위젯 종류 ===
case "exchange":
return <ExchangeWidget element={element} />;
@ -54,7 +43,14 @@ function renderWidget(element: DashboardElement) {
case "calculator":
return <CalculatorWidget element={element} />;
case "clock":
return <ClockWidget element={element} />;
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
<div className="text-center">
<div className="mb-2 text-3xl"></div>
<div className="text-sm"> ( )</div>
</div>
</div>
);
case "map-summary":
return <MapSummaryWidget element={element} />;
case "list-summary":
@ -65,7 +61,7 @@ function renderWidget(element: DashboardElement) {
return <CalendarWidget element={element} />;
case "status-summary":
return <StatusSummaryWidget element={element} />;
// === 운영/작업 지원 ===
case "todo":
return <TodoWidget element={element} />;
@ -76,8 +72,8 @@ function renderWidget(element: DashboardElement) {
case "document":
return <DocumentWidget element={element} />;
case "list":
return <ListWidget element={element} />;
return <ListSummaryWidget element={element} />;
// === 차량 관련 (추가 위젯) ===
case "vehicle-status":
return <VehicleStatusWidget />;
@ -85,7 +81,7 @@ function renderWidget(element: DashboardElement) {
return <VehicleListWidget />;
case "vehicle-map":
return <VehicleMapOnlyWidget element={element} />;
// === 배송 관련 (추가 위젯) ===
case "delivery-status":
return <DeliveryStatusWidget />;
@ -97,7 +93,7 @@ function renderWidget(element: DashboardElement) {
return <CargoListWidget />;
case "customer-issues":
return <CustomerIssuesWidget />;
// === 기본 fallback ===
default:
return (
@ -127,6 +123,11 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
// elements 변경 감지
React.useEffect(() => {
console.log("🎯 DashboardViewer elements 변경됨:", elements.map(el => ({ id: el.id, customTitle: el.customTitle, title: el.title })));
}, [elements]);
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
@ -267,10 +268,12 @@ interface ViewerElementProps {
*/
function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false);
console.log("🖼️ ViewerElement 렌더링:", element.id, "customTitle:", element.customTitle, "title:", element.title);
return (
<div
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
className={`absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm ${element.showHeader === false ? 'p-0' : ''}`}
style={{
left: element.position.x,
top: element.position.y,
@ -280,33 +283,40 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 헤더 */}
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.title}</h3>
{/* 헤더 (showHeader가 true일 때만 표시) */}
{element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.title}</h3>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
)}
</div>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
)}
</div>
)}
{/* 내용 */}
<div className="h-[calc(100%-57px)]">
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
{element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
) : (
<ChartRenderer key={`${element.id}-${element.customTitle || 'default'}`} element={element} data={data} width={element.size.width} height={element.size.height} />
) : element.subtype === "map-summary" ? (
// 지도는 key 없이 렌더링 (Leaflet 초기화 문제 방지)
renderWidget(element)
) : (
<div key={`${element.id}-${element.customTitle || 'default'}`}>
{renderWidget(element)}
</div>
)}
</div>

View File

@ -149,7 +149,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
}
return (
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-rose-50">
<div className={`flex h-full flex-col bg-gradient-to-br from-slate-50 to-rose-50 ${element?.showHeader === false ? 'p-0' : ''}`}>
{/* 신규 알림 배너 */}
{showNotification && newCount > 0 && (
<div className="animate-pulse border-b border-rose-300 bg-rose-100 px-4 py-2 text-center">
@ -158,10 +158,10 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
)}
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className={element?.showHeader === false ? "border-b border-gray-200 bg-white px-2 py-1" : "border-b border-gray-200 bg-white px-4 py-3"}>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-gray-800">🔔 {element?.customTitle || "예약 요청 알림"}</h3>
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || element?.title || "예약 요청 알림"}</h3>
{newCount > 0 && (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{newCount}

View File

@ -118,10 +118,12 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
};
return (
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
<div className={`h-full w-full bg-gradient-to-br from-slate-50 to-gray-100 ${element?.showHeader !== false ? 'p-3' : 'p-0'} ${className}`}>
<div className="h-full flex flex-col gap-2">
{/* 제목 */}
<h3 className="text-base font-semibold text-gray-900 text-center">🧮 {element?.customTitle || "계산기"}</h3>
{/* 제목 (showHeader가 true일 때만 표시) */}
{element?.showHeader !== false && (
<h3 className="text-base font-semibold text-gray-900 text-center">{element?.customTitle || "계산기"}</h3>
)}
{/* 디스플레이 */}
<div className="bg-white border-2 border-gray-200 rounded-lg p-4 shadow-inner min-h-[80px]">

View File

@ -128,11 +128,11 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
};
return (
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
<div className={`flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50 ${element?.showHeader === false ? 'p-0' : ''}`}>
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className={element?.showHeader === false ? "border-b border-gray-200 bg-white px-2 py-1" : "border-b border-gray-200 bg-white px-4 py-3"}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800">📂 {element?.customTitle || "문서 관리"}</h3>
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || element?.title || "문서 관리"}</h3>
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
+
</button>

View File

@ -135,12 +135,13 @@ export default function ExchangeWidget({
const hasError = error || !exchangeRate;
return (
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4">
{/* 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<h3 className="text-base font-semibold text-gray-900 mb-1">💱 {element?.customTitle || "환율"}</h3>
<p className="text-xs text-gray-500">
<div className={`h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border ${element?.showHeader !== false ? 'p-4' : 'p-0'}`}>
{/* 헤더 (showHeader가 true일 때만 표시) */}
{element?.showHeader !== false && (
<div className="flex items-center justify-between mb-3">
<div className="flex-1">
<h3 className="text-base font-semibold text-gray-900 mb-1">{element?.customTitle || "환율"}</h3>
<p className="text-xs text-gray-500">
{lastUpdated
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
hour: '2-digit',
@ -149,16 +150,17 @@ export default function ExchangeWidget({
: ''}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={fetchExchangeRate}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={fetchExchangeRate}
disabled={loading}
className="h-8 w-8 p-0"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
)}
{/* 통화 선택 */}
<div className="flex items-center gap-2 mb-3">

View File

@ -163,32 +163,19 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
};
return (
<div className="flex h-full w-full flex-col gap-3 overflow-hidden bg-slate-50 p-3">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
<h3 className="text-base font-semibold text-gray-900">{element?.customTitle || "리스크 / 알림"}</h3>
{stats.high > 0 && (
<Badge className="bg-red-100 text-red-700 hover:bg-red-100"> {stats.high}</Badge>
)}
<div className={`flex h-full w-full flex-col gap-3 overflow-hidden bg-slate-50 ${element?.showHeader !== false ? 'p-3' : 'p-0'}`}>
{/* 헤더 (showHeader가 true일 때만 표시) */}
{element?.showHeader !== false && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-600" />
<h3 className="text-base font-semibold text-gray-900">{element?.customTitle || "리스크 / 알림"}</h3>
</div>
<Badge variant="destructive" className="text-xs">
{stats.high}
</Badge>
</div>
<div className="flex items-center gap-2">
{lastUpdated && newAlertIds.size > 0 && (
<Badge className="bg-blue-100 text-blue-700 text-xs animate-pulse">
{newAlertIds.size}
</Badge>
)}
{lastUpdated && (
<span className="text-xs text-gray-500">
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing} className="h-8 px-2">
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
)}
{/* 통계 카드 */}
<div className="grid grid-cols-3 gap-2">

View File

@ -353,20 +353,20 @@ export default function StatusSummaryWidget({
const displayTitle = element.customTitle || (tableName ? `${translateTableName(tableName)} 현황` : title);
return (
<div className={`flex h-full w-full flex-col overflow-hidden bg-gradient-to-br ${bgGradient} p-2`}>
<div className={`flex h-full w-full flex-col overflow-hidden bg-gradient-to-br ${bgGradient} ${element.showHeader !== false ? 'p-2' : 'p-0'}`}>
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className={element.showHeader !== false ? "mb-2 flex flex-shrink-0 items-center justify-between" : "mb-1 flex flex-shrink-0 items-center justify-between px-1 pt-1"}>
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">{icon} {displayTitle}</h3>
<h3 className={element.showHeader !== false ? "text-sm font-bold text-gray-900" : "text-xs font-bold text-gray-900"}>{icon} {displayTitle}</h3>
{totalCount > 0 ? (
<p className="text-xs text-gray-500"> {totalCount.toLocaleString()}</p>
<p className={element.showHeader !== false ? "text-xs text-gray-500" : "text-[10px] text-gray-500"}> {totalCount.toLocaleString()}</p>
) : (
<p className="text-xs text-orange-500"> </p>
<p className={element.showHeader !== false ? "text-xs text-orange-500" : "text-[10px] text-orange-500"}> </p>
)}
</div>
<button
onClick={loadData}
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
className={element.showHeader !== false ? "flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50" : "flex h-6 w-6 items-center justify-center rounded border border-border bg-white p-0 text-[10px] hover:bg-accent disabled:opacity-50"}
disabled={loading}
>
{loading ? "⏳" : "🔄"}
@ -376,7 +376,7 @@ export default function StatusSummaryWidget({
{/* 스크롤 가능한 콘텐츠 영역 */}
<div className="flex-1 overflow-y-auto">
{/* 상태별 카드 */}
<div className="grid grid-cols-2 gap-1.5">
<div className={element.showHeader !== false ? "grid grid-cols-2 gap-1.5" : "grid grid-cols-2 gap-0.5 px-0.5 pb-0.5"}>
{statusData.map((item) => {
const colors = getColorClasses(item.status);
return (

View File

@ -194,11 +194,11 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
}
return (
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
<div className={`flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50 ${element?.showHeader === false ? 'p-0' : ''}`}>
{/* 헤더 */}
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className={element?.showHeader === false ? "border-b border-gray-200 bg-white px-2 py-1" : "border-b border-gray-200 bg-white px-4 py-3"}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800"> {element?.customTitle || "To-Do / 긴급 지시"}</h3>
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || element?.title || "To-Do / 긴급 지시"}</h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"

View File

@ -308,11 +308,12 @@ export default function WeatherWidget({
}
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={`h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border ${element?.showHeader !== false ? 'p-4' : 'p-0'}`}>
{/* 헤더 (showHeader가 true일 때만 표시) */}
{element?.showHeader !== false && (
<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>
@ -405,16 +406,17 @@ export default function WeatherWidget({
</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>
<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))' }}>