자잘한 오류 수정과 스크롤, 헤더 변경완료
This commit is contained in:
parent
e53bdd15ef
commit
0a02a6c7ab
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
// 기상청 API Hub - 지상관측시간자료
|
||||
const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php';
|
||||
console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`);
|
||||
|
||||
console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`);
|
||||
// 기상청 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}, 기간: ${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 {
|
||||
// 예상치 못한 오류 → 테스트 데이터 반환
|
||||
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: '기상청 API 호출 중 오류가 발생했습니다.',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -526,12 +526,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)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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) {
|
||||
|
|
@ -505,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}
|
||||
|
|
@ -591,38 +574,60 @@ 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 "📅 달력 위젯";
|
||||
return "달력 위젯";
|
||||
case "driver-management":
|
||||
return "🚚 기사 관리 위젯";
|
||||
return "기사 관리 위젯";
|
||||
case "list":
|
||||
return "📋 리스트 위젯";
|
||||
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 "warehouse-3d":
|
||||
return "🏭 창고 현황 (3D)";
|
||||
return "창고 현황 (3D)";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
return "위젯";
|
||||
}
|
||||
}
|
||||
return "요소";
|
||||
|
|
|
|||
|
|
@ -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="📋"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,6 +249,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
)}
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
{!isHeaderOnlyWidget && (
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
|
|
@ -310,13 +313,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
</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" />
|
||||
저장
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 테이블 뷰 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) {
|
|||
return (
|
||||
<Card className={`flex h-full flex-col ${isFullscreen ? "fixed inset-0 z-50" : ""}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-lg font-bold">🏭 창고 현황 (3D)</CardTitle>
|
||||
<CardTitle className="text-lg font-bold">{element?.customTitle || "창고 현황 (3D)"}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">
|
||||
{warehouses.length}개 창고 | {materials.length}개 자재
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export default function CalculatorWidget({ element, className = '' }: Calculator
|
|||
<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]">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -323,11 +323,9 @@ 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>
|
||||
{/* 제목 - 항상 표시 */}
|
||||
<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" />
|
||||
|
|
@ -335,6 +333,11 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
</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"
|
||||
|
|
@ -346,7 +349,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<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>
|
||||
|
|
@ -367,7 +370,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
)}
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="flex gap-2">
|
||||
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
|
|
@ -383,6 +386,7 @@ export default function TodoWidget({ element }: TodoWidgetProps) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{showAddForm && (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue