diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json index 766a08ed..5274d604 100644 --- a/backend-node/data/todos/todos.json +++ b/backend-node/data/todos/todos.json @@ -11,5 +11,70 @@ "updatedAt": "2025-10-20T09:00:26.948Z", "isUrgent": false, "order": 3 + }, + { + "id": "c8292b4d-bb45-487c-aa29-55b78580b837", + "title": "오늘의 힐일", + "description": "이거 데이터베이스랑 연결하기", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-23T14:04", + "createdAt": "2025-10-23T05:04:50.249Z", + "updatedAt": "2025-10-23T05:04:50.249Z", + "isUrgent": false, + "order": 4 + }, + { + "id": "2c7f90a3-947c-4693-8525-7a2a707172c0", + "title": "테스트용 일정", + "description": "ㅁㄴㅇㄹ", + "priority": "low", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-16T18:16", + "createdAt": "2025-10-23T05:13:14.076Z", + "updatedAt": "2025-10-23T05:13:14.076Z", + "isUrgent": false, + "order": 5 + }, + { + "id": "499feff6-92c7-45a9-91fa-ca727edf90f2", + "title": "ㅁSdf", + "description": "asdfsdfs", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:15:38.430Z", + "updatedAt": "2025-10-23T05:15:38.430Z", + "isUrgent": false, + "order": 6 + }, + { + "id": "166c3910-9908-457f-8c72-8d0183f12e2f", + "title": "ㅎㄹㅇㄴ", + "description": "ㅎㄹㅇㄴ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:21:01.515Z", + "updatedAt": "2025-10-23T05:21:01.515Z", + "isUrgent": false, + "order": 7 + }, + { + "id": "bfa9d476-bb98-41d5-9d74-b016be011bba", + "title": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ", + "description": "ㅁㄴㅇㄹㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-23T05:21:25.781Z", + "updatedAt": "2025-10-23T05:21:25.781Z", + "isUrgent": false, + "order": 8 } ] \ No newline at end of file diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 601e035c..48df8c8f 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -441,7 +441,7 @@ export class DashboardController { } /** - * 쿼리 실행 + * 쿼리 실행 (SELECT만) * POST /api/dashboards/execute-query */ async executeQuery(req: AuthenticatedRequest, res: Response): Promise { @@ -506,6 +506,79 @@ export class DashboardController { } } + /** + * DML 쿼리 실행 (INSERT, UPDATE, DELETE) + * POST /api/dashboards/execute-dml + */ + async executeDML(req: AuthenticatedRequest, res: Response): Promise { + try { + const { query } = req.body; + + // 유효성 검증 + if (!query || typeof query !== "string" || query.trim().length === 0) { + res.status(400).json({ + success: false, + message: "쿼리가 필요합니다.", + }); + return; + } + + // SQL 인젝션 방지를 위한 기본적인 검증 + const trimmedQuery = query.trim().toLowerCase(); + const allowedCommands = ["insert", "update", "delete"]; + const isAllowed = allowedCommands.some((cmd) => + trimmedQuery.startsWith(cmd) + ); + + if (!isAllowed) { + res.status(400).json({ + success: false, + message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.", + }); + return; + } + + // 위험한 명령어 차단 + const dangerousPatterns = [ + /drop\s+table/i, + /drop\s+database/i, + /truncate/i, + /alter\s+table/i, + /create\s+table/i, + ]; + + if (dangerousPatterns.some((pattern) => pattern.test(query))) { + res.status(403).json({ + success: false, + message: "허용되지 않는 쿼리입니다.", + }); + return; + } + + // 쿼리 실행 + const result = await PostgreSQLService.query(query.trim()); + + res.status(200).json({ + success: true, + data: { + rowCount: result.rowCount || 0, + command: result.command, + }, + message: "쿼리가 성공적으로 실행되었습니다.", + }); + } catch (error) { + console.error("DML execution error:", error); + res.status(500).json({ + success: false, + message: "쿼리 실행 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "쿼리 실행 오류", + }); + } + } + /** * 외부 API 프록시 (CORS 우회용) * POST /api/dashboards/fetch-external-api diff --git a/backend-node/src/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index b84dc218..d7cf570e 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe clouds = 30; } + // 격자좌표 → 위도경도 변환 + const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny); + return { city: gridCoord.name, country: 'KR', + lat, + lng, temperature: Math.round(temperature), feelsLike: Math.round(temperature - 2), humidity: Math.round(humidity), @@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe return grids[city] || null; } +/** + * 격자좌표(nx, ny)를 위도경도로 변환 + * 기상청 격자 → 위경도 변환 공식 사용 + */ +function gridToLatLng(nx: number, ny: number): { lat: number; lng: number } { + const RE = 6371.00877; // 지구 반경(km) + const GRID = 5.0; // 격자 간격(km) + const SLAT1 = 30.0; // 표준위도1(degree) + const SLAT2 = 60.0; // 표준위도2(degree) + const OLON = 126.0; // 기준점 경도(degree) + const OLAT = 38.0; // 기준점 위도(degree) + const XO = 43; // 기준점 X좌표 + const YO = 136; // 기준점 Y좌표 + + const DEGRAD = Math.PI / 180.0; + const re = RE / GRID; + const slat1 = SLAT1 * DEGRAD; + const slat2 = SLAT2 * DEGRAD; + const olon = OLON * DEGRAD; + const olat = OLAT * DEGRAD; + + const sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5); + const sn_log = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn); + const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5); + const sf_pow = Math.pow(sf, sn_log); + const sf_result = (Math.cos(slat1) * sf_pow) / sn_log; + const ro = Math.tan(Math.PI * 0.25 + olat * 0.5); + const ro_pow = Math.pow(ro, sn_log); + const ro_result = (re * sf_result) / ro_pow; + + const xn = nx - XO; + const yn = ro_result - (ny - YO); + const ra = Math.sqrt(xn * xn + yn * yn); + let alat: number; + + if (sn_log > 0) { + alat = 2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) - Math.PI * 0.5; + } else { + alat = -2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) + Math.PI * 0.5; + } + + let theta: number; + if (Math.abs(xn) <= 0.0) { + theta = 0.0; + } else { + if (Math.abs(yn) <= 0.0) { + theta = 0.0; + } else { + theta = Math.atan2(xn, yn); + } + } + const alon = theta / sn_log + olon; + + return { + lat: parseFloat((alat / DEGRAD).toFixed(6)), + lng: parseFloat((alon / DEGRAD).toFixed(6)), + }; +} + /** * 공공데이터포털 초단기실황 응답 파싱 * @param apiResponse - 공공데이터포털 API 응답 데이터 @@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string; weatherDescription = '추움'; } + // 격자좌표 → 위도경도 변환 + const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny); + return { city: gridInfo.name, + lat, + lng, temperature: Math.round(temperature * 10) / 10, humidity: Math.round(humidity), windSpeed: Math.round(windSpeed * 10) / 10, diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index 87db696b..2356d05d 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -24,12 +24,18 @@ router.get( dashboardController.getDashboard.bind(dashboardController) ); -// 쿼리 실행 (인증 불필요 - 개발용) +// 쿼리 실행 (SELECT만, 인증 불필요 - 개발용) router.post( "/execute-query", dashboardController.executeQuery.bind(dashboardController) ); +// DML 쿼리 실행 (INSERT/UPDATE/DELETE, 인증 불필요 - 개발용) +router.post( + "/execute-dml", + dashboardController.executeDML.bind(dashboardController) +); + // 외부 API 프록시 (CORS 우회) router.post( "/fetch-external-api", diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 27a20dcb..530e510e 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -78,7 +78,7 @@ const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/Ris loading: () =>
로딩 중...
, }); -const TodoWidget = dynamic(() => import("@/components/dashboard/widgets/TodoWidget"), { +const TaskWidget = dynamic(() => import("@/components/dashboard/widgets/TaskWidget"), { ssr: false, loading: () =>
로딩 중...
, }); @@ -88,11 +88,6 @@ const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/ loading: () =>
로딩 중...
, }); -const MaintenanceWidget = dynamic(() => import("@/components/dashboard/widgets/MaintenanceWidget"), { - ssr: false, - loading: () =>
로딩 중...
, -}); - const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { ssr: false, loading: () =>
로딩 중...
, @@ -922,25 +917,20 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "custom-metric" ? ( - // 사용자 커스텀 카드 위젯 렌더링 + // 사용자 커스텀 카드 위젯 렌더링 (main에서 추가)
- ) : element.type === "widget" && element.subtype === "todo" ? ( - // To-Do 위젯 렌더링 + ) : element.type === "widget" && (element.subtype === "todo" || element.subtype === "maintenance") ? ( + // Task 위젯 렌더링 (To-Do + 정비 일정 통합, lhj)
- +
) : element.type === "widget" && element.subtype === "booking-alert" ? ( // 예약 요청 알림 위젯 렌더링
- ) : element.type === "widget" && element.subtype === "maintenance" ? ( - // 정비 일정 위젯 렌더링 -
- -
) : element.type === "widget" && element.subtype === "document" ? ( // 문서 다운로드 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index b5357dd2..6392b355 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -190,14 +190,14 @@ export function DashboardTopMenu({ 일반 위젯 날씨 + {/* 날씨 지도 */} 환율 계산기 달력 시계 - 할 일 + 일정관리 위젯 {/* 예약 알림 */} - 정비 일정 - {/* 문서 */} + 문서 리스크 알림 {/* 범용 위젯으로 대체 가능하여 주석처리 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index b168fb2c..44ae4a55 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -67,15 +67,42 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 모달이 열릴 때 초기화 useEffect(() => { if (isOpen) { - setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + const dataSourceToSet = element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }; + setDataSource(dataSourceToSet); setChartConfig(element.chartConfig || {}); setQueryResult(null); setCurrentStep(1); setCustomTitle(element.customTitle || ""); setShowHeader(element.showHeader !== false); // showHeader 초기화 + + // 쿼리가 이미 있으면 자동 실행 + if (dataSourceToSet.type === "database" && dataSourceToSet.query) { + console.log("🔄 기존 쿼리 자동 실행:", dataSourceToSet.query); + executeQueryAutomatically(dataSourceToSet); + } } }, [isOpen, element]); + // 쿼리 자동 실행 함수 + const executeQueryAutomatically = async (dataSourceToExecute: ChartDataSource) => { + if (dataSourceToExecute.type !== "database" || !dataSourceToExecute.query) return; + + try { + const { queryApi } = await import("@/lib/api/query"); + const result = await queryApi.executeQuery({ + query: dataSourceToExecute.query, + connectionType: dataSourceToExecute.connectionType || "current", + externalConnectionId: dataSourceToExecute.externalConnectionId, + }); + + console.log("✅ 쿼리 자동 실행 완료:", result); + setQueryResult(result); + } catch (error) { + console.error("❌ 쿼리 자동 실행 실패:", error); + // 실패해도 모달은 열리도록 (사용자가 다시 실행 가능) + } + }; + // 데이터 소스 타입 변경 const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { if (type === "database") { diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index a02cb9cf..e2a5b608 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -35,6 +35,13 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que const [error, setError] = useState(null); const [sampleQueryOpen, setSampleQueryOpen] = useState(false); + // dataSource.query가 변경되면 query state 업데이트 (저장된 쿼리 불러오기) + React.useEffect(() => { + if (dataSource?.query) { + setQuery(dataSource.query); + } + }, [dataSource?.query]); + // 쿼리 실행 const executeQuery = useCallback(async () => { // console.log("🚀 executeQuery 호출됨!"); diff --git a/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx b/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx index 3800105b..55df37d0 100644 --- a/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx +++ b/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx @@ -134,6 +134,37 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
+ {/* 날씨 정보 표시 옵션 */} +
+ +

+ 마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다 +

+
+ +
+ +

+ 현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다 +

+
+ {/* 설정 미리보기 */}
📋 설정 미리보기
@@ -142,6 +173,8 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
경도: {currentConfig.longitudeColumn || '미설정'}
라벨: {currentConfig.labelColumn || '없음'}
상태: {currentConfig.statusColumn || '없음'}
+
날씨 표시: {currentConfig.showWeather ? '활성화' : '비활성화'}
+
기상특보 표시: {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}
데이터 개수: {queryResult.rows.length}개
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 5c82f805..a07b5247 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -15,6 +15,7 @@ export type ElementSubtype = | "combo" // 차트 타입 | "exchange" | "weather" + | "weather-map" // 날씨 지도 위젯 | "clock" | "calendar" | "calculator" @@ -168,6 +169,8 @@ export interface ChartConfig { longitudeColumn?: string; // 경도 컬럼 labelColumn?: string; // 라벨 컬럼 statusColumn?: string; // 상태 컬럼 + showWeather?: boolean; // 날씨 정보 표시 여부 + showWeatherAlerts?: boolean; // 기상특보 영역 표시 여부 } export interface QueryResult { diff --git a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx index 398c4d17..c7895e62 100644 --- a/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx +++ b/frontend/components/admin/dashboard/widgets/TodoWidgetConfigModal.tsx @@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource, QueryResult } from "../types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ChevronLeft, ChevronRight, Save, X } from "lucide-react"; import { DataSourceSelector } from "../data-sources/DataSourceSelector"; import { DatabaseConfig } from "../data-sources/DatabaseConfig"; @@ -19,23 +20,108 @@ interface TodoWidgetConfigModalProps { } /** - * To-Do 위젯 설정 모달 + * 일정관리 위젯 설정 모달 (범용) * - 2단계 설정: 데이터 소스 → 쿼리 입력/테스트 */ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: TodoWidgetConfigModalProps) { const [currentStep, setCurrentStep] = useState<1 | 2>(1); - const [title, setTitle] = useState(element.title || "✅ To-Do / 긴급 지시"); + const [title, setTitle] = useState(element.title || "일정관리 위젯"); const [dataSource, setDataSource] = useState( element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, ); const [queryResult, setQueryResult] = useState(null); + + // 데이터베이스 연동 설정 + const [enableDbSync, setEnableDbSync] = useState(element.chartConfig?.enableDbSync || false); + const [dbSyncMode, setDbSyncMode] = useState<"simple" | "advanced">(element.chartConfig?.dbSyncMode || "simple"); + const [tableName, setTableName] = useState(element.chartConfig?.tableName || ""); + const [columnMapping, setColumnMapping] = useState(element.chartConfig?.columnMapping || { + id: "id", + title: "title", + description: "description", + priority: "priority", + status: "status", + assignedTo: "assigned_to", + dueDate: "due_date", + isUrgent: "is_urgent", + }); // 모달 열릴 때 element에서 설정 로드 useEffect(() => { if (isOpen) { - setTitle(element.title || "✅ To-Do / 긴급 지시"); - if (element.dataSource) { - setDataSource(element.dataSource); + setTitle(element.title || "일정관리 위젯"); + + // 데이터 소스 설정 로드 (저장된 설정 우선, 없으면 기본값) + const loadedDataSource = element.dataSource || { + type: "database", + connectionType: "current", + refreshInterval: 0 + }; + setDataSource(loadedDataSource); + + // 저장된 쿼리가 있으면 자동으로 실행 (실제 결과 가져오기) + if (loadedDataSource.query) { + // 쿼리 자동 실행 + const executeQuery = async () => { + try { + const token = localStorage.getItem("authToken"); + const userLang = localStorage.getItem("userLang") || "KR"; + + const apiUrl = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId + ? `http://localhost:9771/api/external-db/query?userLang=${userLang}` + : `http://localhost:9771/api/dashboards/execute-query?userLang=${userLang}`; + + const requestBody = loadedDataSource.connectionType === "external" && loadedDataSource.externalConnectionId + ? { + connectionId: parseInt(loadedDataSource.externalConnectionId), + query: loadedDataSource.query, + } + : { query: loadedDataSource.query }; + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + }); + + if (response.ok) { + const result = await response.json(); + const rows = result.data?.rows || result.data || []; + setQueryResult({ + rows: rows, + rowCount: rows.length, + executionTime: 0, + }); + } else { + // 실패해도 더미 결과로 2단계 진입 가능 + setQueryResult({ + rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }], + rowCount: 1, + executionTime: 0, + }); + } + } catch (error) { + // 에러 발생해도 2단계 진입 가능 + setQueryResult({ + rows: [{ _info: "저장된 쿼리가 있습니다. 다시 테스트해주세요." }], + rowCount: 1, + executionTime: 0, + }); + } + }; + + executeQuery(); + } + + // DB 동기화 설정 로드 + setEnableDbSync(element.chartConfig?.enableDbSync || false); + setDbSyncMode(element.chartConfig?.dbSyncMode || "simple"); + setTableName(element.chartConfig?.tableName || ""); + if (element.chartConfig?.columnMapping) { + setColumnMapping(element.chartConfig.columnMapping); } setCurrentStep(1); } @@ -94,13 +180,29 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo return; } + // 간편 모드에서 테이블명 필수 체크 + if (enableDbSync && dbSyncMode === "simple" && !tableName.trim()) { + alert("데이터베이스 연동을 활성화하려면 테이블명을 입력해주세요."); + return; + } + onSave({ title, dataSource, + chartConfig: { + ...element.chartConfig, + enableDbSync, + dbSyncMode, + tableName, + columnMapping, + insertQuery: element.chartConfig?.insertQuery, + updateQuery: element.chartConfig?.updateQuery, + deleteQuery: element.chartConfig?.deleteQuery, + }, }); onClose(); - }, [title, dataSource, queryResult, onSave, onClose]); + }, [title, dataSource, queryResult, enableDbSync, dbSyncMode, tableName, columnMapping, element.chartConfig, onSave, onClose]); // 다음 단계로 const handleNext = useCallback(() => { @@ -135,9 +237,9 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo {/* 헤더 */}
-

To-Do 위젯 설정

+

일정관리 위젯 설정

- 데이터 소스와 쿼리를 설정하면 자동으로 To-Do 목록이 표시됩니다 + 데이터 소스와 쿼리를 설정하면 자동으로 일정 목록이 표시됩니다

@@ -213,7 +315,7 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo

💡 컬럼명 가이드

- 쿼리 결과에 다음 컬럼명이 있으면 자동으로 To-Do 항목으로 변환됩니다: + 쿼리 결과에 다음 컬럼명이 있으면 자동으로 일정 항목으로 변환됩니다:

  • @@ -278,7 +380,7 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo

    ✅ 쿼리 테스트 성공!

    - 총 {queryResult.rows.length}개의 To-Do 항목을 찾았습니다. + 총 {queryResult.rows.length}개의 일정 항목을 찾았습니다.

    첫 번째 데이터 미리보기:

    @@ -288,6 +390,232 @@ export function TodoWidgetConfigModal({ isOpen, element, onClose, onSave }: Todo
    )} + + {/* 데이터베이스 연동 쿼리 (선택사항) */} +
    +
    +
    +

    🔗 데이터베이스 연동 (선택사항)

    +

    + 위젯에서 추가/수정/삭제 시 데이터베이스에 직접 반영 +

    +
    + +
    + + {enableDbSync && ( + <> + {/* 모드 선택 */} +
    + + +
    + + {/* 간편 모드 */} + {dbSyncMode === "simple" && ( +
    +

    + 테이블명과 컬럼 매핑만 입력하면 자동으로 INSERT/UPDATE/DELETE 쿼리가 생성됩니다. +

    + + {/* 테이블명 */} +
    + + setTableName(e.target.value)} + placeholder="예: tasks" + className="mt-2" + /> +
    + + {/* 컬럼 매핑 */} +
    + +
    +
    + + setColumnMapping({ ...columnMapping, id: e.target.value })} + placeholder="id" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, title: e.target.value })} + placeholder="title" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, description: e.target.value })} + placeholder="description" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, priority: e.target.value })} + placeholder="priority" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, status: e.target.value })} + placeholder="status" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, assignedTo: e.target.value })} + placeholder="assigned_to" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, dueDate: e.target.value })} + placeholder="due_date" + className="mt-1 h-8 text-sm" + /> +
    +
    + + setColumnMapping({ ...columnMapping, isUrgent: e.target.value })} + placeholder="is_urgent" + className="mt-1 h-8 text-sm" + /> +
    +
    +
    +
    + )} + + {/* 고급 모드 */} + {dbSyncMode === "advanced" && ( +
    +

    + 복잡한 로직이 필요한 경우 직접 쿼리를 작성하세요. +

    + + {/* INSERT 쿼리 */} +
    + +

    + 사용 가능한 변수: ${"{title}"}, ${"{description}"}, ${"{priority}"}, ${"{status}"}, ${"{assignedTo}"}, ${"{dueDate}"}, ${"{isUrgent}"} +

    +