Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard

This commit is contained in:
dohyeons 2025-10-17 16:25:30 +09:00
commit d473ace18d
27 changed files with 471 additions and 280 deletions

View File

@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file
MAINTENANCE_DATA_SOURCE=memory
DOCUMENT_DATA_SOURCE=memory
# OpenWeatherMap API 키 추가 (실시간 날씨)
# https://openweathermap.org/api 에서 무료 가입 후 발급
OPENWEATHER_API_KEY=your_openweathermap_api_key_here

View File

@ -17,19 +17,54 @@ export class OpenApiProxyController {
console.log(`🌤️ 날씨 조회 요청: ${city}`);
// 기상청 API Hub 키 확인
// 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트)
const openWeatherKey = process.env.OPENWEATHER_API_KEY;
if (openWeatherKey) {
try {
console.log(`🌍 OpenWeatherMap API 호출: ${city}`);
const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', {
params: {
q: `${city},KR`,
appid: openWeatherKey,
units: 'metric',
lang: 'kr',
},
timeout: 10000,
});
const data = response.data;
const weatherData = {
city: data.name,
country: data.sys.country,
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(),
};
console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`);
res.json({ success: true, data: weatherData });
return;
} catch (error) {
console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error);
}
}
// 2순위: 기상청 API Hub (매시간 정시 데이터)
const apiKey = process.env.KMA_API_KEY;
// API 키가 없으면 테스트 모드로 실시간 날씨 제공
// API 키가 없으면 오류 반환
if (!apiKey) {
console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.');
const regionCode = getKMARegionCode(city as string);
const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string));
res.json({
success: true,
data: weatherData,
console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.');
res.status(503).json({
success: false,
message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.',
});
return;
}
@ -48,32 +83,39 @@ export class OpenApiProxyController {
// 기상청 API Hub 사용 (apihub.kma.go.kr)
const now = new Date();
// 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능
// 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회
const minute = now.getMinutes();
let targetTime = new Date(now);
// 한국 시간(KST = UTC+9)으로 변환
const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로
const kstNow = new Date(now.getTime() + kstOffset);
if (minute < 10) {
// 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로
targetTime = new Date(now.getTime() - 60 * 60 * 1000);
}
// 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표
// 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정
const targetTime = new Date(kstNow);
// tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회)
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 year = targetTime.getUTCFullYear();
const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
const day = String(targetTime.getUTCDate()).padStart(2, '0');
const hour = String(targetTime.getUTCHours()).padStart(2, '0');
const tm = `${year}${month}${day}${hour}00`;
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
// 기상청 API Hub - 지상관측시간자료
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
// 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보)
// sfctm3: 시간 범위 조회 가능 (tm1~tm2)
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php';
// 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준
const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전
const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`;
const tm2 = tm; // 현재 시간
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`);
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`);
const response = await axios.get(url, {
params: {
tm: tm,
stn: 0, // 0 = 전체 관측소 데이터 조회
tm1: tm1,
tm2: tm2,
stn: regionCode.stnId, // 특정 관측소만 조회
authKey: apiKey,
help: 0,
disp: 1,
@ -95,30 +137,36 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 날씨 조회 실패:', error);
// API 호출 실패 시 자동으로 테스트 모드로 전
// 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,
});
if (status === 401 || status === 403) {
res.status(401).json({
success: false,
message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.',
});
} else if (status === 404) {
res.status(404).json({
success: false,
message: '기상청 API에서 데이터를 찾을 수 없습니다.',
});
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
res.status(504).json({
success: false,
message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
});
} else {
res.status(500).json({
success: false,
message: '기상청 API 호출 중 오류가 발생했습니다.',
error: error.message,
});
}
} 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,
res.status(500).json({
success: false,
message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
@ -169,15 +217,19 @@ export class OpenApiProxyController {
} catch (error: unknown) {
console.error('❌ 환율 조회 실패:', error);
// API 호출 실패 시 실제 근사값 반환
console.log('⚠️ API 오류 발생. 근사값을 반환합니다.');
const { base = 'KRW', target = 'USD' } = req.query;
const approximateRate = generateRealisticExchangeRate(base as string, target as string);
res.json({
success: true,
data: approximateRate,
});
// API 호출 실패 시 명확한 오류 메시지 반환
if (axios.isAxiosError(error)) {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 오류가 발생했습니다.',
error: error.message,
});
} else {
res.status(500).json({
success: false,
message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.',
});
}
}
}
@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st
throw new Error('날씨 데이터를 파싱할 수 없습니다.');
}
// 요청한 관측소(stnId)의 데이터 찾기
const targetLine = lines.find((line: string) => {
// 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨)
const targetLines = lines.filter((line: string) => {
const cols = line.trim().split(/\s+/);
return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1)
});
if (!targetLine) {
if (targetLines.length === 0) {
throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`);
}
// 가장 최근 데이터 선택 (마지막 줄)
const targetLine = targetLines[targetLines.length - 1];
// 데이터 라인 파싱 (공백으로 구분)
const values = targetLine.trim().split(/\s+/);
// 관측 시각 로깅
const obsTime = values[0]; // YYMMDDHHMI
console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`);
// 기상청 API Hub 데이터 형식 (실제 응답 기준):
// [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ...
const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11)

View File

@ -0,0 +1,52 @@
import express from "express";
import { query } from "../database/db";
const router = express.Router();
/**
* API
* - active/warning
*/
router.post("/move", async (req, res) => {
try {
// move_vehicles() 함수 실행
await query("SELECT move_vehicles()");
res.json({
success: true,
message: "차량 위치가 업데이트되었습니다"
});
} catch (error) {
console.error("차량 위치 업데이트 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 업데이트 실패"
});
}
});
/**
*
*/
router.get("/locations", async (req, res) => {
try {
const result = await query(`
SELECT * FROM vehicle_locations
ORDER BY last_update DESC
`);
res.json({
success: true,
data: result.rows
});
} catch (error) {
console.error("차량 위치 조회 오류:", error);
res.status(500).json({
success: false,
error: "차량 위치 조회 실패"
});
}
});
export default router;

View File

@ -105,8 +105,8 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
return (
<div className="h-screen bg-gray-50">
{/* 대시보드 헤더 */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
{/* <div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
@ -114,7 +114,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
</div>
<div className="flex items-center gap-3">
{/* 새로고침 버튼 */}
{/* *\/}
<button
onClick={loadDashboard}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
@ -123,7 +123,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
🔄
</button>
{/* 전체화면 버튼 */}
{/* *\/}
<button
onClick={() => {
if (document.fullscreenElement) {
@ -138,7 +138,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
</button>
{/* 편집 버튼 */}
{/* *\/}
<button
onClick={() => {
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
@ -150,22 +150,20 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
</div>
</div>
{/* 메타 정보 */}
{/* *\/}
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>: {new Date(dashboard.createdAt).toLocaleString()}</span>
<span>: {new Date(dashboard.updatedAt).toLocaleString()}</span>
<span>: {dashboard.elements.length}</span>
</div>
</div>
</div> */}
{/* 대시보드 뷰어 */}
<div className="h-[calc(100vh-120px)]">
<DashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
backgroundColor={dashboard.settings?.backgroundColor}
/>
</div>
<DashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
backgroundColor={dashboard.settings?.backgroundColor}
/>
</div>
);
}

View File

@ -432,7 +432,7 @@ export function CanvasElement({
});
}
} catch (error) {
console.error("Chart data loading error:", error);
// console.error("Chart data loading error:", error);
setChartData(null);
} finally {
setIsLoadingData(false);
@ -521,12 +521,9 @@ export function CanvasElement({
<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.customTitle || element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
{onConfigure &&
!(
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) && (
!(element.type === "widget" && 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)}

View File

@ -98,7 +98,7 @@ export function ChartConfigPanel({
setDateColumns(schema.dateColumns);
})
.catch((error) => {
console.error("❌ 테이블 스키마 조회 실패:", error);
// console.error("❌ 테이블 스키마 조회 실패:", error);
// 실패 시 빈 배열 (날짜 필터 비활성화)
setDateColumns([]);
});

View File

@ -6,7 +6,6 @@ import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { TodoWidgetConfigModal } from "./widgets/TodoWidgetConfigModal";
import { DashboardSaveModal } from "./DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
@ -185,7 +184,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
console.error("Invalid coordinates:", { x, y });
// console.error("Invalid coordinates:", { x, y });
return;
}
@ -207,14 +206,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 크기 유효성 검사
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
console.error("Invalid size calculated:", {
canvasConfig,
cellSize,
cellWithGap,
defaultCells,
defaultWidth,
defaultHeight,
});
// console.error("Invalid size calculated:", {
// canvasConfig,
// cellSize,
// cellWithGap,
// defaultCells,
// defaultWidth,
// defaultHeight,
// });
return;
}
@ -244,7 +243,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
console.error("Invalid canvas config:", canvasConfig);
// console.error("Invalid canvas config:", canvasConfig);
return;
}
@ -310,15 +309,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
[configModalElement, updateElement],
);
const saveTodoWidgetConfig = useCallback(
(updates: Partial<DashboardElement>) => {
if (configModalElement) {
updateElement(configModalElement.id, updates);
}
},
[configModalElement, updateElement],
);
// 레이아웃 저장
const saveLayout = useCallback(() => {
if (elements.length === 0) {
@ -470,7 +460,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
/>
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
<div className="flex flex-1 items-start justify-center overflow-auto bg-gray-100 p-8">
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
<div
className="relative shadow-2xl"
style={{
@ -504,13 +495,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onClose={closeConfigModal}
onSave={saveListWidgetConfig}
/>
) : configModalElement.type === "widget" && configModalElement.subtype === "todo" ? (
<TodoWidgetConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveTodoWidgetConfig}
/>
) : (
<ElementConfigModal
element={configModalElement}
@ -590,34 +574,56 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === "chart") {
switch (subtype) {
case "bar":
return "📊 바 차트";
return "바 차트";
case "horizontal-bar":
return "📊 수평 바 차트";
return "수평 바 차트";
case "stacked-bar":
return "누적 바 차트";
case "pie":
return "🥧 원형 차트";
return "원형 차트";
case "donut":
return "도넛 차트";
case "line":
return "📈 꺾은선 차트";
return "꺾은선 차트";
case "area":
return "영역 차트";
case "combo":
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 "달력 위젯";
case "driver-management":
return "기사 관리 위젯";
case "list":
return "리스트 위젯";
case "map-summary":
return "커스텀 지도 카드";
case "status-summary":
return "커스텀 상태 카드";
case "risk-alert":
return "리스크 알림 위젯";
case "todo":
return "할 일 위젯";
case "booking-alert":
return "예약 알림 위젯";
case "maintenance":
return "정비 일정 위젯";
case "document":
return "문서 위젯";
case "yard-management-3d":
return "야드 관리 3D";
default:

View File

@ -83,7 +83,7 @@ export function DashboardSaveModal({
setAdminMenus(adminMenuList);
setUserMenus(userMenuList);
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
// console.error("메뉴 목록 로드 실패:", error);
setAdminMenus([]);
setUserMenus([]);
} finally {
@ -157,7 +157,7 @@ export function DashboardSaveModal({
});
onClose();
} catch (error) {
console.error("저장 실패:", error);
// console.error("저장 실패:", error);
} finally {
setLoading(false);
}

View File

@ -47,56 +47,48 @@ export function DashboardSidebar() {
{expandedSections.charts && (
<div className="space-y-2">
<DraggableItem
icon="📊"
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="수평 바 차트"
type="chart"
subtype="horizontal-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📚"
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📈"
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📉"
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🥧"
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🍩"
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="콤보 차트"
type="chart"
subtype="combo"
@ -123,70 +115,60 @@ export function DashboardSidebar() {
{expandedSections.widgets && (
<div className="space-y-2">
<DraggableItem
icon="💱"
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="☁️"
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🧮"
title="계산기 위젯"
type="widget"
subtype="calculator"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="⏰"
title="시계 위젯"
type="widget"
subtype="clock"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📍"
title="커스텀 지도 카드"
type="widget"
subtype="map-summary"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📋"
{/* <DraggableItem
title="커스텀 목록 카드"
type="widget"
subtype="list-summary"
onDragStart={handleDragStart}
/>
/> */}
<DraggableItem
icon="⚠️"
title="리스크/알림 위젯"
type="widget"
subtype="risk-alert"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="✅"
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📅"
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📊"
title="커스텀 상태 카드"
type="widget"
subtype="status-summary"
@ -213,14 +195,12 @@ export function DashboardSidebar() {
{expandedSections.operations && (
<div className="space-y-2">
<DraggableItem
icon="✅"
title="To-Do / 긴급 지시"
type="widget"
subtype="todo"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="🔔"
title="예약 요청 알림"
type="widget"
subtype="booking-alert"
@ -228,14 +208,12 @@ export function DashboardSidebar() {
/>
{/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */}
<DraggableItem
icon="📂"
title="문서 다운로드"
type="widget"
subtype="document"
onDragStart={handleDragStart}
/>
<DraggableItem
icon="📋"
title="리스트 위젯"
type="widget"
subtype="list"
@ -249,7 +227,7 @@ export function DashboardSidebar() {
}
interface DraggableItemProps {
icon: string;
icon?: string;
title: string;
type: ElementType;
subtype: ElementSubtype;
@ -260,7 +238,7 @@ interface DraggableItemProps {
/**
*
*/
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
function DraggableItem({ title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
return (
<div
draggable

View File

@ -98,7 +98,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
setQueryResult(result);
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
// console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
setChartConfig({});
}, []);
@ -126,7 +126,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
showHeader, // 헤더 표시 여부
};
console.log(" 저장할 element:", updatedElement);
// console.log(" 저장할 element:", updatedElement);
onSave(updatedElement);
onClose();
@ -135,11 +135,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (
element.type === "widget" &&
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
) {
// 시계, 달력, To-Do 위젯은 헤더 설정만 가능
const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo");
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
if (element.type === "widget" && element.subtype === "driver-management") {
return null;
}
@ -212,16 +212,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder={"예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
/>
<p className="mt-1 text-xs text-gray-500">
💡 (: "maintenance_schedules 목록")
</p>
<p className="mt-1 text-xs text-gray-500"> (: "maintenance_schedules 목록")</p>
</div>
{/* 헤더 표시 여부 */}
<div className="mt-4 flex items-center">
{/* 헤더 표시 옵션 */}
<div className="mt-3 flex items-center gap-2">
<input
type="checkbox"
id="showHeader"
@ -229,14 +231,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
onChange={(e) => setShowHeader(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<label htmlFor="showHeader" className="ml-2 block text-sm text-gray-700">
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
( + )
</label>
</div>
</div>
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
{!isSimpleWidget && (
{/* 진행 상황 표시 - 간단한 위젯과 헤더 전용 위젯은 표시 안 함 */}
{!isSimpleWidget && !isHeaderOnlyWidget && (
<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">
@ -247,12 +249,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
)}
{/* 단계별 내용 */}
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{!isHeaderOnlyWidget && (
<div className="flex-1 overflow-auto p-6">
{currentStep === 1 && (
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
)}
{currentStep === 2 && (
{currentStep === 2 && (
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
{/* 왼쪽: 데이터 설정 */}
<div className="space-y-6">
@ -308,15 +311,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* 모달 푸터 */}
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
<div>{queryResult && <Badge variant="default">{queryResult.rows.length} </Badge>}</div>
<div className="flex gap-3">
{!isSimpleWidget && currentStep > 1 && (
{!isSimpleWidget && !isHeaderOnlyWidget && currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
<ChevronLeft className="mr-2 h-4 w-4" />
@ -325,14 +329,20 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
<Button variant="outline" onClick={onClose}>
</Button>
{currentStep === 1 ? (
// 1단계: 다음 버튼 (모든 타입 공통)
{isHeaderOnlyWidget ? (
// 헤더 전용 위젯: 바로 저장
<Button onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
) : currentStep === 1 ? (
// 1단계: 다음 버튼
<Button onClick={handleNext}>
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
) : (
// 2단계: 저장 버튼 (모든 타입 공통)
// 2단계: 저장 버튼
<Button onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />

View File

@ -61,7 +61,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
setUserMenus(userResponse.data || []);
}
} catch (error) {
console.error("메뉴 목록 로드 실패:", error);
// console.error("메뉴 목록 로드 실패:", error);
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);

View File

@ -35,9 +35,9 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 쿼리 실행
const executeQuery = useCallback(async () => {
console.log("🚀 executeQuery 호출됨!");
console.log("📝 현재 쿼리:", query);
console.log("✅ query.trim():", query.trim());
// console.log("🚀 executeQuery 호출됨!");
// console.log("📝 현재 쿼리:", query);
// console.log("✅ query.trim():", query.trim());
if (!query.trim()) {
setError("쿼리를 입력해주세요.");
@ -47,13 +47,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 외부 DB인 경우 커넥션 ID 확인
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
setError("외부 DB 커넥션을 선택해주세요.");
console.log("❌ 쿼리가 비어있음!");
// console.log("❌ 쿼리가 비어있음!");
return;
}
setIsExecuting(true);
setError(null);
console.log("🔄 쿼리 실행 시작...");
// console.log("🔄 쿼리 실행 시작...");
try {
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
@ -247,7 +247,7 @@ ORDER BY Q4 DESC;`,
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="z-[99999]">
<SelectItem value="0"></SelectItem>
<SelectItem value="10000">10</SelectItem>
<SelectItem value="30000">30</SelectItem>

View File

@ -219,8 +219,9 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
return (
<div className="flex h-full w-full flex-col p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
</div>
{/* 테이블 뷰 */}

View File

@ -131,7 +131,7 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
// 저장
const handleSave = () => {
onSave({
title,
customTitle: title,
dataSource,
listConfig,
});
@ -166,10 +166,19 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
id="list-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
e.stopPropagation();
}}
placeholder="예: 사용자 목록"
className="mt-1"
/>
</div>
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">
💡
</div>
</div>
{/* 진행 상태 표시 */}

View File

@ -67,17 +67,17 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo
// 쿼리 실행 결과 처리
const handleQueryTest = useCallback(
(result: QueryResult) => {
console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
console.log("📊 쿼리 결과:", result);
console.log("📝 rows 개수:", result.rows?.length);
console.log("❌ error:", result.error);
// console.log("🎯 TodoWidget - handleQueryTest 호출됨!");
// console.log("📊 쿼리 결과:", result);
// console.log("📝 rows 개수:", result.rows?.length);
// console.log("❌ error:", result.error);
setQueryResult(result);
console.log("✅ setQueryResult 호출 완료!");
// console.log("✅ setQueryResult 호출 완료!");
// 강제 리렌더링 확인
setTimeout(() => {
console.log("🔄 1초 후 queryResult 상태:", result);
}, 1000);
// setTimeout(() => {
// console.log("🔄 1초 후 queryResult 상태:", result);
// }, 1000);
},
[],
);
@ -318,8 +318,8 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo
onClick={handleSave}
disabled={(() => {
const isDisabled = !queryResult || queryResult.error || !queryResult.rows || queryResult.rows.length === 0;
console.log("💾 저장 버튼 disabled:", isDisabled);
console.log("💾 queryResult:", queryResult);
// console.log("💾 저장 버튼 disabled:", isDisabled);
// console.log("💾 queryResult:", queryResult);
return isDisabled;
})()}
>

View File

@ -256,7 +256,8 @@ export function DashboardViewer({
return (
<DashboardProvider>
<div className="flex h-full items-start justify-center overflow-auto bg-gray-100 p-8">
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex h-full items-start justify-center bg-gray-100 p-8">
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
<div
className="relative overflow-hidden rounded-lg"

View File

@ -161,7 +161,7 @@ export default function BookingAlertWidget({ element }: BookingAlertWidgetProps)
<div className="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 || "예약 요청 알림"}</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

@ -7,7 +7,7 @@
* -
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { DashboardElement } from '@/components/admin/dashboard/types';
@ -117,11 +117,62 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
setDisplay(String(value / 100));
};
// 키보드 입력 처리
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key;
// 숫자 키 (0-9)
if (/^[0-9]$/.test(key)) {
event.preventDefault();
handleNumber(key);
}
// 연산자 키
else if (key === '+' || key === '-' || key === '*' || key === '/') {
event.preventDefault();
handleOperation(key);
}
// 소수점
else if (key === '.') {
event.preventDefault();
handleDecimal();
}
// Enter 또는 = (계산)
else if (key === 'Enter' || key === '=') {
event.preventDefault();
handleEquals();
}
// Escape 또는 c (초기화)
else if (key === 'Escape' || key.toLowerCase() === 'c') {
event.preventDefault();
handleClear();
}
// Backspace (지우기)
else if (key === 'Backspace') {
event.preventDefault();
handleBackspace();
}
// % (퍼센트)
else if (key === '%') {
event.preventDefault();
handlePercent();
}
};
// 이벤트 리스너 등록
window.addEventListener('keydown', handleKeyDown);
// 클린업
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [display, previousValue, operation, waitingForOperand]);
return (
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
<div className="h-full flex flex-col gap-2">
{/* 제목 */}
<h3 className="text-base font-semibold text-gray-900 text-center">🧮 {element?.customTitle || "계산기"}</h3>
<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

@ -150,7 +150,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<h3 className="text-lg font-semibold text-foreground"> /</h3>
<button
onClick={loadData}
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"

View File

@ -130,7 +130,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
{/* 헤더 */}
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800">📅 </h3>
<h3 className="text-lg font-semibold text-gray-800"> </h3>
<button
onClick={loadData}
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"

View File

@ -132,7 +132,7 @@ export default function DocumentWidget({ element }: DocumentWidgetProps) {
{/* 헤더 */}
<div className="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 || "문서 관리"}</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

@ -139,7 +139,7 @@ export default function ExchangeWidget({
{/* 헤더 */}
<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>
<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', {

View File

@ -158,7 +158,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
{/* 헤더 */}
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900">📍 {displayTitle}</h3>
<h3 className="text-sm font-bold text-gray-900">{displayTitle}</h3>
{element?.dataSource?.query ? (
<p className="text-xs text-gray-500"> {markers.length.toLocaleString()} </p>
) : (

View File

@ -64,10 +64,10 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
// 외부 DB 조회 (dataSource가 설정된 경우)
if (element?.dataSource?.query) {
console.log("🔍 TodoWidget - 외부 DB 조회 시작");
console.log("📝 Query:", element.dataSource.query);
console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId);
console.log("🔗 ConnectionType:", element.dataSource.connectionType);
// console.log("🔍 TodoWidget - 외부 DB 조회 시작");
// console.log("📝 Query:", element.dataSource.query);
// console.log("🔗 ConnectionId:", element.dataSource.externalConnectionId);
// console.log("🔗 ConnectionType:", element.dataSource.connectionType);
// 현재 DB vs 외부 DB 분기
const apiUrl = element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId
@ -83,8 +83,8 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
query: element.dataSource.query,
};
console.log("🌐 API URL:", apiUrl);
console.log("📦 Request Body:", requestBody);
// console.log("🌐 API URL:", apiUrl);
// console.log("📦 Request Body:", requestBody);
const response = await fetch(apiUrl, {
method: "POST",
@ -95,29 +95,29 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
body: JSON.stringify(requestBody),
});
console.log("📡 Response status:", response.status);
// console.log("📡 Response status:", response.status);
if (response.ok) {
const result = await response.json();
console.log("✅ API 응답:", result);
console.log("📦 result.data:", result.data);
console.log("📦 result.data.rows:", result.data?.rows);
// console.log("✅ API 응답:", result);
// console.log("📦 result.data:", result.data);
// console.log("📦 result.data.rows:", result.data?.rows);
// API 응답 형식에 따라 데이터 추출
const rows = result.data?.rows || result.data || [];
console.log("📊 추출된 rows:", rows);
// console.log("📊 추출된 rows:", rows);
const externalTodos = mapExternalDataToTodos(rows);
console.log("📋 변환된 Todos:", externalTodos);
console.log("📋 변환된 Todos 개수:", externalTodos.length);
// console.log("📋 변환된 Todos:", externalTodos);
// console.log("📋 변환된 Todos 개수:", externalTodos.length);
setTodos(externalTodos);
setStats(calculateStatsFromTodos(externalTodos));
console.log("✅ setTodos, setStats 호출 완료!");
// console.log("✅ setTodos, setStats 호출 완료!");
} else {
const errorText = await response.text();
console.error("❌ API 오류:", errorText);
// console.error("❌ API 오류:", errorText);
}
}
// 내장 API 조회 (기본)
@ -323,67 +323,71 @@ 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="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-800"> {element?.customTitle || "To-Do / 긴급 지시"}</h3>
{selectedDate && (
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
<CalendarIcon className="h-3 w-3" />
<span className="font-semibold">{formatSelectedDate()} </span>
</div>
)}
</div>
<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"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
{/* 제목 - 항상 표시 */}
<div className="border-b border-gray-200 bg-white px-4 py-2">
<h3 className="text-lg font-bold text-gray-800">{element?.customTitle || "To-Do / 긴급 지시"}</h3>
{selectedDate && (
<div className="mt-1 flex items-center gap-1 text-xs text-green-600">
<CalendarIcon className="h-3 w-3" />
<span className="font-semibold">{formatSelectedDate()} </span>
</div>
)}
{/* 필터 */}
<div className="mt-3 flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
))}
</div>
</div>
{/* 헤더 (추가 버튼, 통계, 필터) - showHeader가 false일 때만 숨김 */}
{element?.showHeader !== false && (
<div className="border-b border-gray-200 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-end">
<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"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* 통계 */}
{stats && (
<div className="grid grid-cols-4 gap-2 text-xs mb-3">
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
<div className="font-bold text-blue-700">{stats.pending}</div>
<div className="text-blue-600"></div>
</div>
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
<div className="font-bold text-amber-700">{stats.inProgress}</div>
<div className="text-amber-600"></div>
</div>
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
<div className="font-bold text-red-700">{stats.urgent}</div>
<div className="text-red-600"></div>
</div>
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
<div className="font-bold text-rose-700">{stats.overdue}</div>
<div className="text-rose-600"></div>
</div>
</div>
)}
{/* 필터 */}
<div className="flex gap-2">
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
filter === f
? "bg-primary text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
</button>
))}
</div>
</div>
)}
{/* 추가 폼 */}
{showAddForm && (
<div className="border-b border-gray-200 bg-white p-4">

View File

@ -97,7 +97,7 @@ export default function VehicleListWidget({ element, refreshInterval = 30000 }:
{/* 헤더 */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-gray-900">📋 </h3>
<h3 className="text-lg font-bold text-gray-900"> </h3>
<p className="text-xs text-gray-500"> : {lastUpdate.toLocaleTimeString("ko-KR")}</p>
</div>
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">

View File

@ -280,9 +280,12 @@ export default function WeatherWidget({
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-2">
<div className="flex flex-col items-center gap-3">
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-600"> ...</p>
<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>
);
@ -290,10 +293,27 @@ export default function WeatherWidget({
// 에러 상태
if (error || !weather) {
const isTestMode = error?.includes('API 키가 설정되지 않았습니다');
return (
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
<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" />
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
<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"

View File

@ -457,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside>
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
<main className="min-w-0 flex-1 overflow-auto bg-white">{children}</main>
<main className="min-w-0 flex-1 bg-white">{children}</main>
</div>
{/* 프로필 수정 모달 */}