From aa3cd95a36ea19d17cff88e9c9901871db28e733 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 23 Oct 2025 15:11:10 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=A0=EC=94=A8=20=EB=9E=91=20todo/=EA=B8=B4?= =?UTF-8?q?=EA=B8=89=EC=9C=84=EC=A0=AF=EC=9D=B4=EB=9E=91=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84=EC=9D=BC=EC=A0=95=20=EC=9C=84=EC=A0=AF=20=ED=95=A9?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/data/todos/todos.json | 65 + .../src/controllers/DashboardController.ts | 75 +- backend-node/src/routes/dashboardRoutes.ts | 8 +- .../admin/dashboard/CanvasElement.tsx | 18 +- .../admin/dashboard/DashboardTopMenu.tsx | 3 +- .../widgets/TodoWidgetConfigModal.tsx | 350 ++- .../components/dashboard/DashboardViewer.tsx | 8 +- .../dashboard/widgets/MapSummaryWidget.tsx | 90 +- .../dashboard/widgets/TaskWidget.tsx | 803 ++++++ frontend/package-lock.json | 2233 +++++++++++++++++ frontend/package.json | 5 + 11 files changed, 3591 insertions(+), 67 deletions(-) create mode 100644 frontend/components/dashboard/widgets/TaskWidget.tsx 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/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 3db56497..35eb134d 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: () =>
로딩 중...
, @@ -915,21 +910,16 @@ export function CanvasElement({
- ) : element.type === "widget" && element.subtype === "todo" ? ( - // To-Do 위젯 렌더링 + ) : element.type === "widget" && (element.subtype === "todo" || element.subtype === "maintenance") ? ( + // Task 위젯 렌더링 (To-Do + 정비 일정 통합)
- +
) : 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 914bfd92..5c292f19 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -196,9 +196,8 @@ export function DashboardTopMenu({ 계산기 달력 시계 - 할 일 + 일정관리 위젯 {/* 예약 알림 */} - 정비 일정 문서 리스크 알림 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}"} +

    +