From 2cc0a7b3091fa5e60f8b22c4e10bb361e4fd72ed Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 8 Dec 2025 10:34:37 +0900 Subject: [PATCH 01/35] =?UTF-8?q?=EB=B0=B0=EC=B9=98=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=ED=83=80=EC=9E=84=EC=A1=B4=EC=9D=84=20Asi?= =?UTF-8?q?a/Seoul=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/batchSchedulerService.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 743c0386..f6fe56a1 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -65,12 +65,18 @@ export class BatchSchedulerService { `배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})` ); - const task = cron.schedule(config.cron_schedule, async () => { - logger.info( - `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` - ); - await this.executeBatchConfig(config); - }); + const task = cron.schedule( + config.cron_schedule, + async () => { + logger.info( + `스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})` + ); + await this.executeBatchConfig(config); + }, + { + timezone: "Asia/Seoul", // 한국 시간 기준으로 스케줄 실행 + } + ); this.scheduledTasks.set(config.id, task); } catch (error) { From 11a99a5c2e80b4bff89e2885cff0947f1b3fb7b1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 8 Dec 2025 16:06:43 +0900 Subject: [PATCH 02/35] =?UTF-8?q?flow-widgdt=20=EC=9D=B8=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=EB=B0=8F=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/flowController.ts | 49 + backend-node/src/routes/flowRoutes.ts | 3 + .../src/services/flowExecutionService.ts | 111 ++ .../components/screen/widgets/FlowWidget.tsx | 1157 ++++++++++++++--- frontend/lib/api/flow.ts | 34 + .../table-list/SingleTableWithSticky.tsx | 132 +- 6 files changed, 1293 insertions(+), 193 deletions(-) diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 9459e1f6..393b33cc 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -837,4 +837,53 @@ export class FlowController { }); } }; + + /** + * 스텝 데이터 업데이트 (인라인 편집) + */ + updateStepData = async (req: Request, res: Response): Promise => { + try { + const { flowId, stepId, recordId } = req.params; + const updateData = req.body; + const userId = (req as any).user?.userId || "system"; + const userCompanyCode = (req as any).user?.companyCode; + + if (!flowId || !stepId || !recordId) { + res.status(400).json({ + success: false, + message: "flowId, stepId, and recordId are required", + }); + return; + } + + if (!updateData || Object.keys(updateData).length === 0) { + res.status(400).json({ + success: false, + message: "Update data is required", + }); + return; + } + + const result = await this.flowExecutionService.updateStepData( + parseInt(flowId), + parseInt(stepId), + recordId, + updateData, + userId, + userCompanyCode + ); + + res.json({ + success: true, + message: "Data updated successfully", + data: result, + }); + } catch (error: any) { + console.error("Error updating step data:", error); + res.status(500).json({ + success: false, + message: error.message || "Failed to update step data", + }); + } + }; } diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index 5816fb8e..e33afac2 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -43,6 +43,9 @@ router.get("/:flowId/steps/counts", flowController.getAllStepCounts); router.post("/move", flowController.moveData); router.post("/move-batch", flowController.moveBatchData); +// ==================== 스텝 데이터 수정 (인라인 편집) ==================== +router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData); + // ==================== 오딧 로그 ==================== router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs); diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 966842b8..53a181e3 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -263,4 +263,115 @@ export class FlowExecutionService { tableName: result[0].table_name, }; } + + /** + * 스텝 데이터 업데이트 (인라인 편집) + * 원본 테이블의 데이터를 직접 업데이트합니다. + */ + async updateStepData( + flowId: number, + stepId: number, + recordId: string, + updateData: Record, + userId: string, + companyCode?: string + ): Promise<{ success: boolean }> { + try { + // 1. 플로우 정의 조회 + const flowDef = await this.flowDefinitionService.findById(flowId); + if (!flowDef) { + throw new Error(`Flow definition not found: ${flowId}`); + } + + // 2. 스텝 조회 + const step = await this.flowStepService.findById(stepId); + if (!step) { + throw new Error(`Flow step not found: ${stepId}`); + } + + // 3. 테이블명 결정 + const tableName = step.tableName || flowDef.tableName; + if (!tableName) { + throw new Error("Table name not found"); + } + + // 4. Primary Key 컬럼 결정 (기본값: id) + const primaryKeyColumn = flowDef.primaryKey || "id"; + + console.log(`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`); + + // 5. SET 절 생성 + const updateColumns = Object.keys(updateData); + if (updateColumns.length === 0) { + throw new Error("No columns to update"); + } + + // 6. 외부 DB vs 내부 DB 구분 + if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { + // 외부 DB 업데이트 + console.log("✅ [updateStepData] Using EXTERNAL DB:", flowDef.dbConnectionId); + + // 외부 DB 연결 정보 조회 + const connectionResult = await db.query( + "SELECT * FROM external_db_connection WHERE id = $1", + [flowDef.dbConnectionId] + ); + + if (connectionResult.length === 0) { + throw new Error(`External DB connection not found: ${flowDef.dbConnectionId}`); + } + + const connection = connectionResult[0]; + const dbType = connection.db_type?.toLowerCase(); + + // DB 타입에 따른 placeholder 및 쿼리 생성 + let setClause: string; + let params: any[]; + + if (dbType === "mysql" || dbType === "mariadb") { + // MySQL/MariaDB: ? placeholder + setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", "); + params = [...Object.values(updateData), recordId]; + } else if (dbType === "mssql") { + // MSSQL: @p1, @p2 placeholder + setClause = updateColumns.map((col, idx) => `[${col}] = @p${idx + 1}`).join(", "); + params = [...Object.values(updateData), recordId]; + } else { + // PostgreSQL: $1, $2 placeholder + setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); + params = [...Object.values(updateData), recordId]; + } + + const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`; + + console.log(`📝 [updateStepData] Query: ${updateQuery}`); + console.log(`📝 [updateStepData] Params:`, params); + + await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params); + } else { + // 내부 DB 업데이트 + console.log("✅ [updateStepData] Using INTERNAL DB"); + + const setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); + const params = [...Object.values(updateData), recordId]; + + const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`; + + console.log(`📝 [updateStepData] Query: ${updateQuery}`); + console.log(`📝 [updateStepData] Params:`, params); + + await db.query(updateQuery, params); + } + + console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, { + updatedFields: updateColumns, + userId, + }); + + return { success: true }; + } catch (error: any) { + console.error("❌ [updateStepData] Error:", error); + throw error; + } + } } diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index ce3d5e27..a4d2dad9 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -1,10 +1,26 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react"; +import { + AlertCircle, + Loader2, + ChevronUp, + Filter, + X, + Layers, + ChevronDown, + ChevronRight, + ChevronLeft, + Edit, + FileSpreadsheet, + FileText, + Copy, + RefreshCw, +} from "lucide-react"; +import * as XLSX from "xlsx"; import { getFlowById, getAllStepCounts, @@ -16,6 +32,8 @@ import { import type { FlowDefinition, FlowStep } from "@/types/flow"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; +import { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky"; +import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; import { @@ -151,6 +169,26 @@ export function FlowWidget({ const [stepDataPage, setStepDataPage] = useState(1); const [stepDataPageSize, setStepDataPageSize] = useState(10); + // 🆕 정렬 상태 (SingleTableWithSticky용) + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + + // 🆕 툴바 관련 상태 + const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); + const [globalSearchTerm, setGlobalSearchTerm] = useState(""); + const [searchHighlights, setSearchHighlights] = useState>(new Set()); + const [currentSearchIndex, setCurrentSearchIndex] = useState(0); + + // 🆕 인라인 편집 관련 상태 + const [editingCell, setEditingCell] = useState<{ + rowIndex: number; + colIndex: number; + columnName: string; + originalValue: any; + } | null>(null); + const [editingValue, setEditingValue] = useState(""); + const editInputRef = useRef(null); + // componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨) const config = (component as any).componentConfig || (component as any).config || {}; const flowId = config.flowId || component.flowId; @@ -763,9 +801,620 @@ export function FlowWidget({ const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== ""); const displayData = hasSearchValue ? filteredData : stepData; - // 🆕 페이지네이션된 스텝 데이터 - const paginatedStepData = displayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); - const totalStepDataPages = Math.ceil(displayData.length / stepDataPageSize); + // 🆕 정렬된 데이터 + const sortedDisplayData = useMemo(() => { + if (!sortColumn) return displayData; + + return [...displayData].sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + // null/undefined 처리 + if (aVal == null && bVal == null) return 0; + if (aVal == null) return sortDirection === "asc" ? 1 : -1; + if (bVal == null) return sortDirection === "asc" ? -1 : 1; + + // 숫자 비교 + if (typeof aVal === "number" && typeof bVal === "number") { + return sortDirection === "asc" ? aVal - bVal : bVal - aVal; + } + + // 문자열 비교 + const aStr = String(aVal).toLowerCase(); + const bStr = String(bVal).toLowerCase(); + if (sortDirection === "asc") { + return aStr.localeCompare(bStr, "ko"); + } + return bStr.localeCompare(aStr, "ko"); + }); + }, [displayData, sortColumn, sortDirection]); + + // 🆕 페이지네이션된 스텝 데이터 (정렬된 데이터 기반) + const paginatedStepData = sortedDisplayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize); + const totalStepDataPages = Math.ceil(sortedDisplayData.length / stepDataPageSize); + + // 🆕 정렬 핸들러 + const handleSort = useCallback((columnName: string) => { + if (sortColumn === columnName) { + // 같은 컬럼 클릭 시 방향 토글 + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + // 다른 컬럼 클릭 시 해당 컬럼으로 오름차순 정렬 + setSortColumn(columnName); + setSortDirection("asc"); + } + }, [sortColumn]); + + // 🆕 SingleTableWithSticky용 컬럼 설정 생성 + const tableColumns: ColumnConfig[] = useMemo(() => { + const cols: ColumnConfig[] = []; + + // 체크박스 컬럼 추가 (allowDataMove가 true일 때) + if (allowDataMove) { + cols.push({ + columnName: "__checkbox__", + displayName: "", + visible: true, + sortable: false, + searchable: false, + width: 50, + align: "center", + order: 0, + }); + } + + // 데이터 컬럼들 추가 + stepDataColumns.forEach((col, index) => { + cols.push({ + columnName: col, + displayName: columnLabels[col] || col, + visible: true, + sortable: true, + searchable: true, + width: 150, + align: "left", + order: index + 1, + }); + }); + + return cols; + }, [stepDataColumns, columnLabels, allowDataMove]); + + // 🆕 SingleTableWithSticky용 테이블 설정 + const tableConfig = useMemo(() => ({ + stickyHeader: true, + checkbox: { + enabled: allowDataMove, + selectAll: allowDataMove, + multiple: true, + position: "left" as const, + }, + tableStyle: { + hoverEffect: true, + alternateRows: false, + }, + }), [allowDataMove]); + + // 🆕 현재 페이지 기준으로 변환된 검색 하이라이트 + const pageSearchHighlights = useMemo(() => { + if (searchHighlights.size === 0) return new Set(); + + const pageStartIndex = (stepDataPage - 1) * stepDataPageSize; + const pageEndIndex = pageStartIndex + stepDataPageSize; + const pageHighlights = new Set(); + + searchHighlights.forEach((key) => { + const [rowIndexStr, colIndexStr] = key.split("-"); + const rowIndex = parseInt(rowIndexStr); + + // 현재 페이지에 해당하는 항목만 포함 + if (rowIndex >= pageStartIndex && rowIndex < pageEndIndex) { + // 페이지 내 상대 인덱스로 변환 + const pageRowIndex = rowIndex - pageStartIndex; + pageHighlights.add(`${pageRowIndex}-${colIndexStr}`); + } + }); + + return pageHighlights; + }, [searchHighlights, stepDataPage, stepDataPageSize]); + + // 🆕 현재 페이지 기준 검색 인덱스 + const pageCurrentSearchIndex = useMemo(() => { + if (searchHighlights.size === 0) return 0; + + const highlightArray = Array.from(searchHighlights); + const currentKey = highlightArray[currentSearchIndex]; + if (!currentKey) return -1; + + const [rowIndexStr, colIndexStr] = currentKey.split("-"); + const rowIndex = parseInt(rowIndexStr); + const pageStartIndex = (stepDataPage - 1) * stepDataPageSize; + const pageRowIndex = rowIndex - pageStartIndex; + + // 현재 페이지에 있는지 확인 + if (pageRowIndex < 0 || pageRowIndex >= stepDataPageSize) return -1; + + // pageSearchHighlights에서의 인덱스 찾기 + const pageKey = `${pageRowIndex}-${colIndexStr}`; + const pageHighlightArray = Array.from(pageSearchHighlights); + return pageHighlightArray.indexOf(pageKey); + }, [searchHighlights, currentSearchIndex, stepDataPage, stepDataPageSize, pageSearchHighlights]); + + // 🆕 컬럼 너비 계산 함수 + const getColumnWidth = useCallback((column: ColumnConfig) => { + if (column.columnName === "__checkbox__") return 50; + return column.width || 150; + }, []); + + // 🆕 셀 값 포맷팅 함수 + const formatCellValue = useCallback((value: any, format?: string, columnName?: string) => { + return formatValue(value); + }, []); + + // 🆕 전체 선택 핸들러 + const handleSelectAll = useCallback((checked: boolean) => { + if (checked) { + const allIndices = new Set(sortedDisplayData.map((_, idx) => idx)); + setSelectedRows(allIndices); + } else { + setSelectedRows(new Set()); + } + }, [sortedDisplayData]); + + // 🆕 행 클릭 핸들러 + const handleRowClick = useCallback((row: any) => { + // 필요 시 행 클릭 로직 추가 + }, []); + + // 🆕 체크박스 셀 렌더링 + const renderCheckboxCell = useCallback((row: any, index: number) => { + return ( + toggleRowSelection(index)} + /> + ); + }, [selectedRows, toggleRowSelection]); + + // 🆕 Excel 내보내기 + const exportToExcel = useCallback(() => { + try { + const exportData = selectedRows.size > 0 + ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + : sortedDisplayData; + + if (exportData.length === 0) { + toast.warning("내보낼 데이터가 없습니다."); + return; + } + + // 컬럼 라벨 적용 + const formattedData = exportData.map((row) => { + const newRow: Record = {}; + stepDataColumns.forEach((col) => { + const label = columnLabels[col] || col; + newRow[label] = row[col]; + }); + return newRow; + }); + + const ws = XLSX.utils.json_to_sheet(formattedData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Data"); + + const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.xlsx`; + XLSX.writeFile(wb, fileName); + + toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); + } catch (error) { + console.error("Excel 내보내기 오류:", error); + toast.error("Excel 내보내기에 실패했습니다."); + } + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName]); + + // 🆕 PDF 내보내기 (html2canvas 사용으로 한글 지원) + const exportToPdf = useCallback(async () => { + try { + const exportData = selectedRows.size > 0 + ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + : sortedDisplayData; + + if (exportData.length === 0) { + toast.warning("내보낼 데이터가 없습니다."); + return; + } + + toast.loading("PDF 생성 중...", { id: "pdf-export" }); + + // html2canvas와 jspdf 동적 로드 + const [{ default: html2canvas }, { default: jsPDF }] = await Promise.all([ + import("html2canvas"), + import("jspdf"), + ]); + + // 임시 테이블 HTML 생성 + const tempContainer = document.createElement("div"); + tempContainer.style.cssText = ` + position: absolute; + left: -9999px; + top: 0; + background: white; + padding: 20px; + font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; + `; + + // 제목 + const title = document.createElement("h2"); + title.textContent = flowName || "Flow Data"; + title.style.cssText = "margin-bottom: 10px; font-size: 18px; color: #333;"; + tempContainer.appendChild(title); + + // 날짜 + const dateInfo = document.createElement("p"); + dateInfo.textContent = `내보내기 일시: ${new Date().toLocaleString("ko-KR")}`; + dateInfo.style.cssText = "margin-bottom: 15px; font-size: 12px; color: #666;"; + tempContainer.appendChild(dateInfo); + + // 테이블 생성 + const table = document.createElement("table"); + table.style.cssText = ` + border-collapse: collapse; + width: 100%; + font-size: 11px; + `; + + // 헤더 + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + stepDataColumns.forEach((col) => { + const th = document.createElement("th"); + th.textContent = columnLabels[col] || col; + th.style.cssText = ` + background: #4a90d9; + color: white; + padding: 8px 12px; + text-align: left; + border: 1px solid #3a7bc8; + white-space: nowrap; + `; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // 바디 + const tbody = document.createElement("tbody"); + exportData.forEach((row, idx) => { + const tr = document.createElement("tr"); + tr.style.cssText = idx % 2 === 0 ? "background: #fff;" : "background: #f9f9f9;"; + stepDataColumns.forEach((col) => { + const td = document.createElement("td"); + td.textContent = String(row[col] ?? ""); + td.style.cssText = ` + padding: 6px 12px; + border: 1px solid #ddd; + white-space: nowrap; + `; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + tempContainer.appendChild(table); + + document.body.appendChild(tempContainer); + + // HTML을 캔버스로 변환 + const canvas = await html2canvas(tempContainer, { + scale: 2, + useCORS: true, + logging: false, + backgroundColor: "#ffffff", + }); + + document.body.removeChild(tempContainer); + + // 캔버스를 PDF로 변환 + const imgData = canvas.toDataURL("image/png"); + const imgWidth = canvas.width; + const imgHeight = canvas.height; + + // A4 가로 방향 (297mm x 210mm) + const pdfWidth = 297; + const pdfHeight = 210; + const ratio = Math.min(pdfWidth / (imgWidth / 3.78), pdfHeight / (imgHeight / 3.78)); + + const doc = new jsPDF({ + orientation: imgWidth > imgHeight ? "landscape" : "portrait", + unit: "mm", + format: "a4", + }); + + const scaledWidth = (imgWidth / 3.78) * ratio * 0.9; + const scaledHeight = (imgHeight / 3.78) * ratio * 0.9; + + // 이미지가 페이지보다 크면 여러 페이지로 분할 + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + + if (scaledHeight <= pageHeight - 20) { + // 한 페이지에 들어가는 경우 + doc.addImage(imgData, "PNG", 10, 10, scaledWidth, scaledHeight); + } else { + // 여러 페이지로 분할 + let remainingHeight = scaledHeight; + let yOffset = 0; + let pageNum = 0; + + while (remainingHeight > 0) { + if (pageNum > 0) { + doc.addPage(); + } + + const drawHeight = Math.min(pageHeight - 20, remainingHeight); + doc.addImage( + imgData, + "PNG", + 10, + 10 - yOffset, + scaledWidth, + scaledHeight + ); + + remainingHeight -= (pageHeight - 20); + yOffset += (pageHeight - 20); + pageNum++; + } + } + + const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.pdf`; + doc.save(fileName); + + toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); + } catch (error) { + console.error("PDF 내보내기 오류:", error); + toast.error("PDF 내보내기에 실패했습니다.", { id: "pdf-export" }); + } + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName]); + + // 🆕 복사 기능 + const handleCopy = useCallback(() => { + try { + const copyData = selectedRows.size > 0 + ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + : []; + + if (copyData.length === 0) { + toast.warning("복사할 데이터를 선택해주세요."); + return; + } + + // 헤더 + 데이터를 탭 구분 텍스트로 변환 + const headers = stepDataColumns.map((col) => columnLabels[col] || col).join("\t"); + const rows = copyData.map((row) => + stepDataColumns.map((col) => String(row[col] ?? "")).join("\t") + ).join("\n"); + + const text = `${headers}\n${rows}`; + navigator.clipboard.writeText(text); + + toast.success(`${copyData.length}개 행이 클립보드에 복사되었습니다.`); + } catch (error) { + console.error("복사 오류:", error); + toast.error("복사에 실패했습니다."); + } + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels]); + + // 🆕 통합 검색 실행 + const executeGlobalSearch = useCallback((term: string) => { + if (!term.trim()) { + setSearchHighlights(new Set()); + return; + } + + const highlights = new Set(); + const lowerTerm = term.toLowerCase(); + + // 전체 데이터에서 검색하여 페이지 이동 및 하이라이트 정보 저장 + sortedDisplayData.forEach((row, rowIndex) => { + stepDataColumns.forEach((col, colIndex) => { + const value = String(row[col] ?? "").toLowerCase(); + if (value.includes(lowerTerm)) { + // 체크박스 컬럼 offset 고려 (allowDataMove가 true면 +1) + const adjustedColIndex = allowDataMove ? colIndex + 1 : colIndex; + highlights.add(`${rowIndex}-${adjustedColIndex}`); + } + }); + }); + + setSearchHighlights(highlights); + setCurrentSearchIndex(0); + + if (highlights.size === 0) { + toast.info("검색 결과가 없습니다."); + } else { + // 첫 번째 검색 결과가 있는 페이지로 이동 + const firstHighlight = Array.from(highlights)[0]; + const [rowIndexStr] = firstHighlight.split("-"); + const rowIndex = parseInt(rowIndexStr); + const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; + setStepDataPage(targetPage); + + toast.success(`${highlights.size}개 결과를 찾았습니다.`); + } + }, [sortedDisplayData, stepDataColumns, allowDataMove, stepDataPageSize]); + + // 🆕 검색 결과 이동 + const goToNextSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + const newIndex = (currentSearchIndex + 1) % searchHighlights.size; + setCurrentSearchIndex(newIndex); + + // 해당 검색 결과가 있는 페이지로 이동 + const highlightArray = Array.from(searchHighlights); + const [rowIndexStr] = highlightArray[newIndex].split("-"); + const rowIndex = parseInt(rowIndexStr); + const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; + if (targetPage !== stepDataPage) { + setStepDataPage(targetPage); + } + }, [searchHighlights, currentSearchIndex, stepDataPageSize, stepDataPage]); + + const goToPrevSearchResult = useCallback(() => { + if (searchHighlights.size === 0) return; + const newIndex = (currentSearchIndex - 1 + searchHighlights.size) % searchHighlights.size; + setCurrentSearchIndex(newIndex); + + // 해당 검색 결과가 있는 페이지로 이동 + const highlightArray = Array.from(searchHighlights); + const [rowIndexStr] = highlightArray[newIndex].split("-"); + const rowIndex = parseInt(rowIndexStr); + const targetPage = Math.floor(rowIndex / stepDataPageSize) + 1; + if (targetPage !== stepDataPage) { + setStepDataPage(targetPage); + } + }, [searchHighlights, currentSearchIndex, stepDataPageSize, stepDataPage]); + + + // 🆕 검색 초기화 + const clearGlobalSearch = useCallback(() => { + setGlobalSearchTerm(""); + setSearchHighlights(new Set()); + setIsSearchPanelOpen(false); + setCurrentSearchIndex(0); + }, []); + + // 🆕 새로고침 + const handleRefresh = useCallback(async () => { + if (!selectedStepId) return; + + setStepDataLoading(true); + try { + const response = await getStepDataList(selectedStepId); + if (response.success && response.data) { + setStepData(response.data.data || []); + if (response.data.columns) { + const currentStep = steps.find((s) => s.id === selectedStepId); + const visibleCols = getVisibleColumns(selectedStepId, response.data.columns, steps); + setStepDataColumns(visibleCols); + setAllAvailableColumns(response.data.columns); + } + } + toast.success("데이터를 새로고침했습니다."); + } catch (error) { + console.error("새로고침 오류:", error); + toast.error("새로고침에 실패했습니다."); + } finally { + setStepDataLoading(false); + } + }, [selectedStepId, steps, getVisibleColumns]); + + // 🆕 셀 더블클릭 시 편집 모드 진입 + const handleCellDoubleClick = useCallback((rowIndex: number, colIndex: number, columnName: string, value: any) => { + // 체크박스 컬럼은 편집 불가 + if (columnName === "__checkbox__") return; + + setEditingCell({ rowIndex, colIndex, columnName, originalValue: value }); + setEditingValue(value !== null && value !== undefined ? String(value) : ""); + }, []); + + // 🆕 편집 취소 + const cancelEditing = useCallback(() => { + setEditingCell(null); + setEditingValue(""); + }, []); + + // 🆕 편집 저장 (플로우 스텝 데이터 업데이트) + const saveEditing = useCallback(async () => { + if (!editingCell || !selectedStepId || !flowId) return; + + const { rowIndex, columnName, originalValue } = editingCell; + const newValue = editingValue; + + // 값이 변경되지 않았으면 그냥 닫기 + if (String(originalValue ?? "") === newValue) { + cancelEditing(); + return; + } + + try { + // 페이지네이션을 고려한 실제 인덱스 계산 + const actualIndex = (stepDataPage - 1) * stepDataPageSize + rowIndex; + + // 현재 행의 데이터 가져오기 (정렬된 전체 데이터에서) + const currentRow = paginatedStepData[rowIndex]; + if (!currentRow) { + toast.error("데이터를 찾을 수 없습니다."); + cancelEditing(); + return; + } + + // Primary Key 값 찾기 (일반적으로 id 또는 첫 번째 컬럼) + // 플로우 정의에서 primaryKey를 가져오거나, 기본값으로 id 사용 + const primaryKeyColumn = flowData?.primaryKey || "id"; + const recordId = currentRow[primaryKeyColumn] || currentRow.id; + + if (!recordId) { + toast.error("레코드 ID를 찾을 수 없습니다. Primary Key 설정을 확인해주세요."); + cancelEditing(); + return; + } + + // API 호출하여 데이터 업데이트 + const { updateFlowStepData } = await import("@/lib/api/flow"); + const response = await updateFlowStepData(flowId, selectedStepId, recordId, { [columnName]: newValue }); + + if (response.success) { + // 로컬 상태 업데이트 + setStepData((prev) => { + const newData = [...prev]; + // 원본 데이터에서 해당 레코드 찾기 + const targetIndex = newData.findIndex((row) => { + const rowRecordId = row[primaryKeyColumn] || row.id; + return rowRecordId === recordId; + }); + if (targetIndex !== -1) { + newData[targetIndex] = { ...newData[targetIndex], [columnName]: newValue }; + } + return newData; + }); + toast.success("데이터가 저장되었습니다."); + } else { + toast.error(response.error || "저장에 실패했습니다."); + } + } catch (error) { + console.error("편집 저장 오류:", error); + toast.error("저장 중 오류가 발생했습니다."); + } + + cancelEditing(); + }, [editingCell, editingValue, selectedStepId, flowId, flowData, paginatedStepData, stepDataPage, stepDataPageSize, cancelEditing]); + + // 🆕 편집 키보드 핸들러 + const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case "Enter": + e.preventDefault(); + saveEditing(); + break; + case "Escape": + e.preventDefault(); + cancelEditing(); + break; + case "Tab": + e.preventDefault(); + saveEditing(); + break; + } + }, [saveEditing, cancelEditing]); + + // 🆕 편집 입력 필드 자동 포커스 + useEffect(() => { + if (editingCell && editInputRef.current) { + editInputRef.current.focus(); + editInputRef.current.select(); + } + }, [editingCell]); if (loading) { return ( @@ -894,83 +1543,223 @@ export function FlowWidget({ {/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && ( -
- {/* 필터 및 그룹 설정 */} +
+ {/* 🆕 DevExpress 스타일 기능 툴바 */} {stepDataColumns.length > 0 && ( <> -
-
- {/* 검색 필터 입력 영역 */} - {searchFilterColumns.size > 0 && ( - <> - {Array.from(searchFilterColumns).map((col) => ( - - setSearchValues((prev) => ({ - ...prev, - [col]: e.target.value, - })) - } - placeholder={`${columnLabels[col] || col} 검색...`} - className="h-8 text-xs w-40" - /> - ))} - {Object.keys(searchValues).length > 0 && ( - - )} - - )} - - {/* 필터/그룹 설정 버튼 */} -
- +
+ {/* 내보내기 버튼들 */} +
+ +
+ + {/* 복사 버튼 */} +
+ +
+ + {/* 선택 정보 */} + {selectedRows.size > 0 && ( +
+ + {selectedRows.size}개 선택됨 + + +
+ )} + + {/* 🆕 통합 검색 패널 */} +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } + }} + placeholder="검색어 입력... (Enter)" + className="border-input bg-background h-7 w-32 rounded border px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary sm:w-48" + autoFocus + /> + {searchHighlights.size > 0 && ( + + {currentSearchIndex + 1}/{searchHighlights.size} + + )} + + + +
+ ) : ( + + )} +
+ + {/* 필터/그룹 설정 버튼 */} +
+ + +
+ + {/* 새로고침 */} +
+ +
+
+ + {/* 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( +
+
+ {Array.from(searchFilterColumns).map((col) => ( + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} 검색...`} + className="h-8 text-xs w-40" + /> + ))} + {Object.keys(searchValues).length > 0 && ( + + )}
-
+ )} {/* 🆕 그룹 표시 배지 */} {groupByColumns.length > 0 && ( @@ -1065,113 +1854,129 @@ export function FlowWidget({
- {/* 데스크톱: 테이블 뷰 - 고정 높이 + 스크롤 */} -
- - - - {allowDataMove && ( - - 0} - onCheckedChange={toggleAllRows} - /> - - )} - {stepDataColumns.map((col) => ( - - {columnLabels[col] || col} - - ))} - - - - {groupByColumns.length > 0 && groupedData.length > 0 ? ( - // 그룹화된 렌더링 - groupedData.flatMap((group) => { - const isCollapsed = collapsedGroups.has(group.groupKey); - const groupRows = [ - - + {groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 (기존 방식 유지) +
+
+ + + {allowDataMove && ( + + 0} + onCheckedChange={toggleAllRows} + /> + + )} + {stepDataColumns.map((col) => ( + handleSort(col)} > -
toggleGroupCollapse(group.groupKey)} - > - {isCollapsed ? ( - - ) : ( - +
+ {columnLabels[col] || col} + {sortColumn === col && ( + + {sortDirection === "asc" ? "↑" : "↓"} + )} - {group.groupKey} - ({group.count}건)
- - , - ]; - - if (!isCollapsed) { - const dataRows = group.items.map((row, itemIndex) => { - const actualIndex = displayData.indexOf(row); - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> - - )} - {stepDataColumns.map((col) => ( - - {formatValue(row[col])} - - ))} - - ); - }); - groupRows.push(...dataRows); - } - - return groupRows; - }) - ) : ( - // 일반 렌더링 (그룹 없음) - paginatedStepData.map((row, pageIndex) => { - const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> + + ))} + + + + {groupedData.flatMap((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + const groupRows = [ + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
- )} - {stepDataColumns.map((col) => ( - - {formatValue(row[col])} - - ))} -
- ); - }) - )} -
-
+ , + ]; + + if (!isCollapsed) { + const dataRows = group.items.map((row, itemIndex) => { + const actualIndex = sortedDisplayData.indexOf(row); + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }); + groupRows.push(...dataRows); + } + + return groupRows; + })} + + +
+ ) : ( + // 일반 렌더링 - SingleTableWithSticky 사용 + 0} + onSort={handleSort} + handleSelectAll={handleSelectAll} + handleRowClick={handleRowClick} + renderCheckboxCell={renderCheckboxCell} + formatCellValue={formatCellValue} + getColumnWidth={getColumnWidth} + loading={stepDataLoading} + // 인라인 편집 props + onCellDoubleClick={handleCellDoubleClick} + editingCell={editingCell} + editingValue={editingValue} + onEditingValueChange={setEditingValue} + onEditKeyDown={handleEditKeyDown} + editInputRef={editInputRef} + // 검색 하이라이트 props (현재 페이지 기준으로 변환된 값) + searchHighlights={pageSearchHighlights} + currentSearchIndex={pageCurrentSearchIndex} + searchTerm={globalSearchTerm} + /> + )}
)} diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index 0a917692..ff2a81a2 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -525,3 +525,37 @@ export async function getFlowAuditLogs(flowId: number, limit: number = 100): Pro }; } } + +// ============================================ +// 플로우 스텝 데이터 수정 API +// ============================================ + +/** + * 플로우 스텝 데이터 업데이트 (인라인 편집) + * @param flowId 플로우 정의 ID + * @param stepId 스텝 ID + * @param recordId 레코드의 primary key 값 + * @param updateData 업데이트할 데이터 + */ +export async function updateFlowStepData( + flowId: number, + stepId: number, + recordId: string | number, + updateData: Record, +): Promise> { + try { + const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/data/${recordId}`, { + method: "PUT", + headers: getAuthHeaders(), + credentials: "include", + body: JSON.stringify(updateData), + }); + + return await response.json(); + } catch (error: any) { + return { + success: false, + error: error.message, + }; + } +} diff --git a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx index 45143dc3..0f11bbf2 100644 --- a/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx @@ -28,6 +28,17 @@ interface SingleTableWithStickyProps { containerWidth?: string; // 컨테이너 너비 설정 loading?: boolean; error?: string | null; + // 인라인 편집 관련 props + onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void; + editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null; + editingValue?: string; + onEditingValueChange?: (value: string) => void; + onEditKeyDown?: (e: React.KeyboardEvent) => void; + editInputRef?: React.RefObject; + // 검색 하이라이트 관련 props + searchHighlights?: Set; + currentSearchIndex?: number; + searchTerm?: string; } export const SingleTableWithSticky: React.FC = ({ @@ -51,6 +62,17 @@ export const SingleTableWithSticky: React.FC = ({ containerWidth, loading = false, error = null, + // 인라인 편집 관련 props + onCellDoubleClick, + editingCell, + editingValue, + onEditingValueChange, + onEditKeyDown, + editInputRef, + // 검색 하이라이트 관련 props + searchHighlights, + currentSearchIndex = 0, + searchTerm = "", }) => { const checkboxConfig = tableConfig?.checkbox || {}; const actualColumns = visibleColumns || columns || []; @@ -58,14 +80,13 @@ export const SingleTableWithSticky: React.FC = ({ return (
-
+
= ({ }} > {actualColumns.map((column, colIndex) => { @@ -215,9 +229,65 @@ export const SingleTableWithSticky: React.FC = ({ ? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0) : 0; + // 현재 셀이 편집 중인지 확인 + const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex; + + // 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인 + const cellKey = `${index}-${colIndex}`; + const cellValue = String(row[column.columnName] ?? "").toLowerCase(); + const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false; + + // 인덱스 기반 하이라이트 + 실제 값 검증 + const isHighlighted = column.columnName !== "__checkbox__" && + hasSearchTerm && + (searchHighlights?.has(cellKey) ?? false); + + // 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음) + const highlightArray = searchHighlights ? Array.from(searchHighlights) : []; + const isCurrentSearchResult = isHighlighted && + currentSearchIndex >= 0 && + currentSearchIndex < highlightArray.length && + highlightArray[currentSearchIndex] === cellKey; + + // 셀 값에서 검색어 하이라이트 렌더링 + const renderCellContent = () => { + const cellValue = formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"; + + if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") { + return cellValue; + } + + // 검색어 하이라이트 처리 + const lowerValue = String(cellValue).toLowerCase(); + const lowerTerm = searchTerm.toLowerCase(); + const startIndex = lowerValue.indexOf(lowerTerm); + + if (startIndex === -1) return cellValue; + + const before = String(cellValue).slice(0, startIndex); + const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length); + const after = String(cellValue).slice(startIndex + searchTerm.length); + + return ( + <> + {before} + + {match} + + {after} + + ); + }; + return ( = ({ "sticky z-10 border-r border-border bg-background/90 backdrop-blur-sm", column.fixed === "right" && "sticky z-10 border-l border-border bg-background/90 backdrop-blur-sm", + // 편집 가능 셀 스타일 + onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text", )} style={{ width: getColumnWidth(column), @@ -239,10 +311,36 @@ export const SingleTableWithSticky: React.FC = ({ ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} + onDoubleClick={(e) => { + if (onCellDoubleClick && column.columnName !== "__checkbox__") { + e.stopPropagation(); + onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]); + } + }} > - {column.columnName === "__checkbox__" - ? renderCheckboxCell(row, index) - : formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0"} + {column.columnName === "__checkbox__" ? ( + renderCheckboxCell(row, index) + ) : isEditing ? ( + // 인라인 편집 입력 필드 + onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={() => { + // blur 시 저장 (Enter와 동일) + if (onEditKeyDown) { + const fakeEvent = { key: "Enter", preventDefault: () => {} } as React.KeyboardEvent; + onEditKeyDown(fakeEvent); + } + }} + className="h-8 w-full rounded border border-primary bg-background px-2 text-xs focus:outline-none focus:ring-2 focus:ring-primary sm:text-sm" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + renderCellContent() + )} ); })} From f0f6c42b3cf4eb45ab89aa1b0bf97fc814910a80 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 8 Dec 2025 16:49:28 +0900 Subject: [PATCH 03/35] =?UTF-8?q?flow-widget=20=EC=9D=B8=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=EC=8B=9C=20changed=5Fby=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=20=EA=B8=B0=EB=A1=9D=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/services/flowExecutionService.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 53a181e3..dcaafb5b 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -360,7 +360,17 @@ export class FlowExecutionService { console.log(`📝 [updateStepData] Query: ${updateQuery}`); console.log(`📝 [updateStepData] Params:`, params); - await db.query(updateQuery, params); + // 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행 + // (트리거에서 changed_by를 기록하기 위함) + await db.query("BEGIN"); + try { + await db.query(`SET LOCAL app.user_id = '${userId}'`); + await db.query(updateQuery, params); + await db.query("COMMIT"); + } catch (txError) { + await db.query("ROLLBACK"); + throw txError; + } } console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, { From 469c8b2e57905d8ddbbd1e4186f1e939565ed082 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 9 Dec 2025 10:18:07 +0900 Subject: [PATCH 04/35] =?UTF-8?q?=EC=A7=80=EC=97=AD=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 30 ++- frontend/lib/constants/regionBounds.ts | 238 ++++++++++++++++++ 2 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 frontend/lib/constants/regionBounds.ts diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 9b0db43a..48545281 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button"; import { Loader2, RefreshCw } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { regionOptions, filterVehiclesByRegion } from "@/lib/constants/regionBounds"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import "leaflet/dist/leaflet.css"; // Popup 말풍선 꼬리 제거 스타일 @@ -101,6 +103,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [routeLoading, setRouteLoading] = useState(false); const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 + // 지역 필터 상태 + const [selectedRegion, setSelectedRegion] = useState("all"); + // dataSources를 useMemo로 추출 (circular reference 방지) const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -1165,6 +1170,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

+ {/* 지역 필터 */} + + {/* 이동경로 날짜 선택 */} {selectedUserId && (
@@ -1442,8 +1461,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ); })} - {/* 마커 렌더링 */} - {markers.map((marker) => { + {/* 마커 렌더링 (지역 필터 적용) */} + {filterVehiclesByRegion(markers, selectedRegion).map((marker) => { // 마커의 소스에 해당하는 데이터 소스 찾기 const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0]; const markerType = sourceDataSource?.markerType || "circle"; @@ -1771,7 +1790,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 하단 정보 */} {(markers.length > 0 || polygons.length > 0) && (
- {markers.length > 0 && `마커 ${markers.length}개`} + {markers.length > 0 && ( + <> + 마커 {filterVehiclesByRegion(markers, selectedRegion).length}개 + {selectedRegion !== "all" && ` (전체 ${markers.length}개)`} + + )} {markers.length > 0 && polygons.length > 0 && " · "} {polygons.length > 0 && `영역 ${polygons.length}개`}
diff --git a/frontend/lib/constants/regionBounds.ts b/frontend/lib/constants/regionBounds.ts new file mode 100644 index 00000000..2b0f15ba --- /dev/null +++ b/frontend/lib/constants/regionBounds.ts @@ -0,0 +1,238 @@ +/** + * 전국 시/도별 좌표 범위 (경계 좌표) + * 차량 위치 필터링에 사용 + */ + +export interface RegionBounds { + south: number; // 최남단 위도 + north: number; // 최북단 위도 + west: number; // 최서단 경도 + east: number; // 최동단 경도 +} + +export interface RegionOption { + value: string; + label: string; + bounds?: RegionBounds; +} + +// 전국 시/도별 좌표 범위 +export const regionBounds: Record = { + // 서울특별시 + seoul: { + south: 37.413, + north: 37.715, + west: 126.734, + east: 127.183, + }, + // 부산광역시 + busan: { + south: 34.879, + north: 35.389, + west: 128.758, + east: 129.314, + }, + // 대구광역시 + daegu: { + south: 35.601, + north: 36.059, + west: 128.349, + east: 128.761, + }, + // 인천광역시 + incheon: { + south: 37.166, + north: 37.592, + west: 126.349, + east: 126.775, + }, + // 광주광역시 + gwangju: { + south: 35.053, + north: 35.267, + west: 126.652, + east: 127.013, + }, + // 대전광역시 + daejeon: { + south: 36.197, + north: 36.488, + west: 127.246, + east: 127.538, + }, + // 울산광역시 + ulsan: { + south: 35.360, + north: 35.710, + west: 128.958, + east: 129.464, + }, + // 세종특별자치시 + sejong: { + south: 36.432, + north: 36.687, + west: 127.044, + east: 127.364, + }, + // 경기도 + gyeonggi: { + south: 36.893, + north: 38.284, + west: 126.387, + east: 127.839, + }, + // 강원도 (강원특별자치도) + gangwon: { + south: 37.017, + north: 38.613, + west: 127.085, + east: 129.359, + }, + // 충청북도 + chungbuk: { + south: 36.012, + north: 37.261, + west: 127.282, + east: 128.657, + }, + // 충청남도 + chungnam: { + south: 35.972, + north: 37.029, + west: 125.927, + east: 127.380, + }, + // 전라북도 (전북특별자치도) + jeonbuk: { + south: 35.287, + north: 36.133, + west: 126.392, + east: 127.923, + }, + // 전라남도 + jeonnam: { + south: 33.959, + north: 35.507, + west: 125.979, + east: 127.921, + }, + // 경상북도 + gyeongbuk: { + south: 35.571, + north: 37.144, + west: 128.113, + east: 130.922, + }, + // 경상남도 + gyeongnam: { + south: 34.599, + north: 35.906, + west: 127.555, + east: 129.224, + }, + // 제주특별자치도 + jeju: { + south: 33.106, + north: 33.959, + west: 126.117, + east: 126.978, + }, +}; + +// 지역 선택 옵션 (드롭다운용) +export const regionOptions: RegionOption[] = [ + { value: "all", label: "전체" }, + { value: "seoul", label: "서울특별시", bounds: regionBounds.seoul }, + { value: "busan", label: "부산광역시", bounds: regionBounds.busan }, + { value: "daegu", label: "대구광역시", bounds: regionBounds.daegu }, + { value: "incheon", label: "인천광역시", bounds: regionBounds.incheon }, + { value: "gwangju", label: "광주광역시", bounds: regionBounds.gwangju }, + { value: "daejeon", label: "대전광역시", bounds: regionBounds.daejeon }, + { value: "ulsan", label: "울산광역시", bounds: regionBounds.ulsan }, + { value: "sejong", label: "세종특별자치시", bounds: regionBounds.sejong }, + { value: "gyeonggi", label: "경기도", bounds: regionBounds.gyeonggi }, + { value: "gangwon", label: "강원특별자치도", bounds: regionBounds.gangwon }, + { value: "chungbuk", label: "충청북도", bounds: regionBounds.chungbuk }, + { value: "chungnam", label: "충청남도", bounds: regionBounds.chungnam }, + { value: "jeonbuk", label: "전북특별자치도", bounds: regionBounds.jeonbuk }, + { value: "jeonnam", label: "전라남도", bounds: regionBounds.jeonnam }, + { value: "gyeongbuk", label: "경상북도", bounds: regionBounds.gyeongbuk }, + { value: "gyeongnam", label: "경상남도", bounds: regionBounds.gyeongnam }, + { value: "jeju", label: "제주특별자치도", bounds: regionBounds.jeju }, +]; + +/** + * 좌표가 특정 지역 범위 내에 있는지 확인 + */ +export function isInRegion( + latitude: number, + longitude: number, + region: string +): boolean { + if (region === "all") return true; + + const bounds = regionBounds[region]; + if (!bounds) return false; + + return ( + latitude >= bounds.south && + latitude <= bounds.north && + longitude >= bounds.west && + longitude <= bounds.east + ); +} + +/** + * 좌표로 지역 찾기 (해당하는 첫 번째 지역 반환) + */ +export function findRegionByCoords( + latitude: number, + longitude: number +): string | null { + for (const [region, bounds] of Object.entries(regionBounds)) { + if ( + latitude >= bounds.south && + latitude <= bounds.north && + longitude >= bounds.west && + longitude <= bounds.east + ) { + return region; + } + } + return null; +} + +/** + * 차량 목록을 지역별로 필터링 + */ +export function filterVehiclesByRegion< + T extends { latitude?: number; longitude?: number; lat?: number; lng?: number } +>(vehicles: T[], region: string): T[] { + if (region === "all") return vehicles; + + const bounds = regionBounds[region]; + if (!bounds) return vehicles; + + return vehicles.filter((v) => { + const lat = v.latitude ?? v.lat; + const lng = v.longitude ?? v.lng; + + if (lat === undefined || lng === undefined) return false; + + return ( + lat >= bounds.south && + lat <= bounds.north && + lng >= bounds.west && + lng <= bounds.east + ); + }); +} + +/** + * 지역명(한글) 가져오기 + */ +export function getRegionLabel(regionValue: string): string { + const option = regionOptions.find((opt) => opt.value === regionValue); + return option?.label ?? regionValue; +} + From 987120f13bf8f4af1d5709294efddbf3a37339bc Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Dec 2025 10:47:15 +0900 Subject: [PATCH 05/35] =?UTF-8?q?=EC=B0=B8=EC=A1=B0=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/node-editor/FlowEditor.tsx | 2 - .../node-editor/nodes/ReferenceLookupNode.tsx | 108 --- .../node-editor/panels/PropertiesPanel.tsx | 5 - .../properties/ReferenceLookupProperties.tsx | 706 ------------------ .../node-editor/sidebar/nodePaletteConfig.ts | 8 - frontend/types/node-editor.ts | 31 - 6 files changed, 860 deletions(-) delete mode 100644 frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx delete mode 100644 frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index f74d35aa..333a70c1 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -18,7 +18,6 @@ import { ValidationNotification } from "./ValidationNotification"; import { FlowToolbar } from "./FlowToolbar"; import { TableSourceNode } from "./nodes/TableSourceNode"; import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode"; -import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode"; import { ConditionNode } from "./nodes/ConditionNode"; import { InsertActionNode } from "./nodes/InsertActionNode"; import { UpdateActionNode } from "./nodes/UpdateActionNode"; @@ -38,7 +37,6 @@ const nodeTypes = { tableSource: TableSourceNode, externalDBSource: ExternalDBSourceNode, restAPISource: RestAPISourceNode, - referenceLookup: ReferenceLookupNode, // 변환/조건 condition: ConditionNode, dataTransform: DataTransformNode, diff --git a/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx b/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx deleted file mode 100644 index 181d7dad..00000000 --- a/frontend/components/dataflow/node-editor/nodes/ReferenceLookupNode.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -/** - * 참조 테이블 조회 노드 (내부 DB 전용) - * 다른 테이블에서 데이터를 조회하여 조건 비교나 필드 매핑에 사용 - */ - -import { memo } from "react"; -import { Handle, Position, NodeProps } from "reactflow"; -import { Link2, Database } from "lucide-react"; -import type { ReferenceLookupNodeData } from "@/types/node-editor"; - -export const ReferenceLookupNode = memo(({ data, selected }: NodeProps) => { - return ( -
- {/* 헤더 */} -
- -
-
{data.displayName || "참조 조회"}
-
-
- - {/* 본문 */} -
-
- - 내부 DB 참조 -
- - {/* 참조 테이블 */} - {data.referenceTable && ( -
-
📋 참조 테이블
-
- {data.referenceTableLabel || data.referenceTable} -
-
- )} - - {/* 조인 조건 */} - {data.joinConditions && data.joinConditions.length > 0 && ( -
-
🔗 조인 조건:
-
- {data.joinConditions.map((join, idx) => ( -
- {join.sourceFieldLabel || join.sourceField} - - {join.referenceFieldLabel || join.referenceField} -
- ))} -
-
- )} - - {/* WHERE 조건 */} - {data.whereConditions && data.whereConditions.length > 0 && ( -
-
⚡ WHERE 조건:
-
{data.whereConditions.length}개 조건
-
- )} - - {/* 출력 필드 */} - {data.outputFields && data.outputFields.length > 0 && ( -
-
📤 출력 필드:
-
- {data.outputFields.slice(0, 3).map((field, idx) => ( -
-
- {field.alias} - ← {field.fieldLabel || field.fieldName} -
- ))} - {data.outputFields.length > 3 && ( -
... 외 {data.outputFields.length - 3}개
- )} -
-
- )} -
- - {/* 입력 핸들 (왼쪽) */} - - - {/* 출력 핸들 (오른쪽) */} - -
- ); -}); - -ReferenceLookupNode.displayName = "ReferenceLookupNode"; - - diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index cf7c7e6e..67483e03 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -8,7 +8,6 @@ import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { TableSourceProperties } from "./properties/TableSourceProperties"; -import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties"; import { InsertActionProperties } from "./properties/InsertActionProperties"; import { ConditionProperties } from "./properties/ConditionProperties"; import { UpdateActionProperties } from "./properties/UpdateActionProperties"; @@ -99,9 +98,6 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "tableSource": return ; - case "referenceLookup": - return ; - case "insertAction": return ; @@ -161,7 +157,6 @@ function getNodeTypeLabel(type: NodeType): string { tableSource: "테이블 소스", externalDBSource: "외부 DB 소스", restAPISource: "REST API 소스", - referenceLookup: "참조 조회", condition: "조건 분기", fieldMapping: "필드 매핑", dataTransform: "데이터 변환", diff --git a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx deleted file mode 100644 index b2bb51e0..00000000 --- a/frontend/components/dataflow/node-editor/panels/properties/ReferenceLookupProperties.tsx +++ /dev/null @@ -1,706 +0,0 @@ -"use client"; - -/** - * 참조 테이블 조회 노드 속성 편집 - */ - -import { useEffect, useState, useCallback } from "react"; -import { Plus, Trash2, Search, Check, ChevronsUpDown } from "lucide-react"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { cn } from "@/lib/utils"; -import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; -import type { ReferenceLookupNodeData } from "@/types/node-editor"; -import { tableTypeApi } from "@/lib/api/screen"; - -// 필드 정의 -interface FieldDefinition { - name: string; - label?: string; - type?: string; -} - -interface ReferenceLookupPropertiesProps { - nodeId: string; - data: ReferenceLookupNodeData; -} - -const OPERATORS = [ - { value: "=", label: "같음 (=)" }, - { value: "!=", label: "같지 않음 (≠)" }, - { value: ">", label: "보다 큼 (>)" }, - { value: "<", label: "보다 작음 (<)" }, - { value: ">=", label: "크거나 같음 (≥)" }, - { value: "<=", label: "작거나 같음 (≤)" }, - { value: "LIKE", label: "포함 (LIKE)" }, - { value: "IN", label: "IN" }, -] as const; - -export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) { - const { updateNode, nodes, edges } = useFlowEditorStore(); - - // 상태 - const [displayName, setDisplayName] = useState(data.displayName || "참조 조회"); - const [referenceTable, setReferenceTable] = useState(data.referenceTable || ""); - const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || ""); - const [joinConditions, setJoinConditions] = useState(data.joinConditions || []); - const [whereConditions, setWhereConditions] = useState(data.whereConditions || []); - const [outputFields, setOutputFields] = useState(data.outputFields || []); - - // 소스 필드 수집 - const [sourceFields, setSourceFields] = useState([]); - - // 참조 테이블 관련 - const [tables, setTables] = useState([]); - const [tablesLoading, setTablesLoading] = useState(false); - const [tablesOpen, setTablesOpen] = useState(false); - const [referenceColumns, setReferenceColumns] = useState([]); - const [columnsLoading, setColumnsLoading] = useState(false); - - // Combobox 열림 상태 관리 - const [whereFieldOpenState, setWhereFieldOpenState] = useState([]); - - // 데이터 변경 시 로컬 상태 동기화 - useEffect(() => { - setDisplayName(data.displayName || "참조 조회"); - setReferenceTable(data.referenceTable || ""); - setReferenceTableLabel(data.referenceTableLabel || ""); - setJoinConditions(data.joinConditions || []); - setWhereConditions(data.whereConditions || []); - setOutputFields(data.outputFields || []); - }, [data]); - - // whereConditions 변경 시 whereFieldOpenState 초기화 - useEffect(() => { - setWhereFieldOpenState(new Array(whereConditions.length).fill(false)); - }, [whereConditions.length]); - - // 🔍 소스 필드 수집 (업스트림 노드에서) - useEffect(() => { - const incomingEdges = edges.filter((e) => e.target === nodeId); - const fields: FieldDefinition[] = []; - - for (const edge of incomingEdges) { - const sourceNode = nodes.find((n) => n.id === edge.source); - if (!sourceNode) continue; - - const sourceData = sourceNode.data as any; - - if (sourceNode.type === "tableSource" && sourceData.fields) { - fields.push(...sourceData.fields); - } else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) { - fields.push(...sourceData.outputFields); - } - } - - setSourceFields(fields); - }, [nodeId, nodes, edges]); - - // 📊 테이블 목록 로드 - useEffect(() => { - loadTables(); - }, []); - - const loadTables = async () => { - setTablesLoading(true); - try { - const data = await tableTypeApi.getTables(); - setTables(data); - } catch (error) { - console.error("테이블 로드 실패:", error); - } finally { - setTablesLoading(false); - } - }; - - // 📋 참조 테이블 컬럼 로드 - useEffect(() => { - if (referenceTable) { - loadReferenceColumns(); - } else { - setReferenceColumns([]); - } - }, [referenceTable]); - - const loadReferenceColumns = async () => { - if (!referenceTable) return; - - setColumnsLoading(true); - try { - const cols = await tableTypeApi.getColumns(referenceTable); - const formatted = cols.map((col: any) => ({ - name: col.columnName, - type: col.dataType, - label: col.displayName || col.columnName, - })); - setReferenceColumns(formatted); - } catch (error) { - console.error("컬럼 로드 실패:", error); - setReferenceColumns([]); - } finally { - setColumnsLoading(false); - } - }; - - // 테이블 선택 핸들러 - const handleTableSelect = (tableName: string) => { - const selectedTable = tables.find((t) => t.tableName === tableName); - if (selectedTable) { - setReferenceTable(tableName); - setReferenceTableLabel(selectedTable.label); - setTablesOpen(false); - - // 기존 설정 초기화 - setJoinConditions([]); - setWhereConditions([]); - setOutputFields([]); - } - }; - - // 조인 조건 추가 - const handleAddJoinCondition = () => { - setJoinConditions([ - ...joinConditions, - { - sourceField: "", - referenceField: "", - }, - ]); - }; - - const handleRemoveJoinCondition = (index: number) => { - setJoinConditions(joinConditions.filter((_, i) => i !== index)); - }; - - const handleJoinConditionChange = (index: number, field: string, value: any) => { - const newConditions = [...joinConditions]; - newConditions[index] = { ...newConditions[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "sourceField") { - const sourceField = sourceFields.find((f) => f.name === value); - newConditions[index].sourceFieldLabel = sourceField?.label || value; - } else if (field === "referenceField") { - const refField = referenceColumns.find((f) => f.name === value); - newConditions[index].referenceFieldLabel = refField?.label || value; - } - - setJoinConditions(newConditions); - }; - - // WHERE 조건 추가 - const handleAddWhereCondition = () => { - const newConditions = [ - ...whereConditions, - { - field: "", - operator: "=", - value: "", - valueType: "static", - }, - ]; - setWhereConditions(newConditions); - setWhereFieldOpenState(new Array(newConditions.length).fill(false)); - }; - - const handleRemoveWhereCondition = (index: number) => { - const newConditions = whereConditions.filter((_, i) => i !== index); - setWhereConditions(newConditions); - setWhereFieldOpenState(new Array(newConditions.length).fill(false)); - }; - - const handleWhereConditionChange = (index: number, field: string, value: any) => { - const newConditions = [...whereConditions]; - newConditions[index] = { ...newConditions[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "field") { - const refField = referenceColumns.find((f) => f.name === value); - newConditions[index].fieldLabel = refField?.label || value; - } - - setWhereConditions(newConditions); - }; - - // 출력 필드 추가 - const handleAddOutputField = () => { - setOutputFields([ - ...outputFields, - { - fieldName: "", - alias: "", - }, - ]); - }; - - const handleRemoveOutputField = (index: number) => { - setOutputFields(outputFields.filter((_, i) => i !== index)); - }; - - const handleOutputFieldChange = (index: number, field: string, value: any) => { - const newFields = [...outputFields]; - newFields[index] = { ...newFields[index], [field]: value }; - - // 라벨도 함께 저장 - if (field === "fieldName") { - const refField = referenceColumns.find((f) => f.name === value); - newFields[index].fieldLabel = refField?.label || value; - // alias 자동 설정 - if (!newFields[index].alias) { - newFields[index].alias = `ref_${value}`; - } - } - - setOutputFields(newFields); - }; - - const handleSave = () => { - updateNode(nodeId, { - displayName, - referenceTable, - referenceTableLabel, - joinConditions, - whereConditions, - outputFields, - }); - }; - - const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable; - - return ( -
-
- {/* 기본 정보 */} -
-

기본 정보

- -
-
- - setDisplayName(e.target.value)} - className="mt-1" - placeholder="노드 표시 이름" - /> -
- - {/* 참조 테이블 선택 */} -
- - - - - - - - - - 검색 결과가 없습니다. - - - {tables.map((table) => ( - handleTableSelect(table.tableName)} - className="cursor-pointer" - > - -
- {table.label} - {table.label !== table.tableName && ( - {table.tableName} - )} -
-
- ))} -
-
-
-
-
-
-
-
-
- - {/* 조인 조건 */} -
-
-

조인 조건 (FK 매핑)

- -
- - {joinConditions.length > 0 ? ( -
- {joinConditions.map((condition, index) => ( -
-
- 조인 #{index + 1} - -
- -
-
- - -
- -
- - -
-
-
- ))} -
- ) : ( -
- 조인 조건을 추가하세요 (필수) -
- )} -
- - {/* WHERE 조건 */} -
-
-

WHERE 조건 (선택사항)

- -
- - {whereConditions.length > 0 && ( -
- {whereConditions.map((condition, index) => ( -
-
- WHERE #{index + 1} - -
- -
- {/* 필드 - Combobox */} -
- - { - const newState = [...whereFieldOpenState]; - newState[index] = open; - setWhereFieldOpenState(newState); - }} - > - - - - - - - - 필드를 찾을 수 없습니다. - - {referenceColumns.map((field) => ( - { - handleWhereConditionChange(index, "field", currentValue); - const newState = [...whereFieldOpenState]; - newState[index] = false; - setWhereFieldOpenState(newState); - }} - className="text-xs sm:text-sm" - > - -
- {field.label || field.name} - {field.type && ( - {field.type} - )} -
-
- ))} -
-
-
-
-
-
- -
- - -
- -
- - -
- -
- - {condition.valueType === "field" ? ( - - ) : ( - handleWhereConditionChange(index, "value", e.target.value)} - placeholder="비교할 값" - className="mt-1 h-8 text-xs" - /> - )} -
-
-
- ))} -
- )} -
- - {/* 출력 필드 */} -
-
-

출력 필드

- -
- - {outputFields.length > 0 ? ( -
- {outputFields.map((field, index) => ( -
-
- 필드 #{index + 1} - -
- -
-
- - -
- -
- - handleOutputFieldChange(index, "alias", e.target.value)} - placeholder="ref_field_name" - className="mt-1 h-8 text-xs" - /> -
-
-
- ))} -
- ) : ( -
- 출력 필드를 추가하세요 (필수) -
- )} -
- - {/* 저장 버튼 */} - - {/* 안내 */} -
-
- 🔗 조인 조건: 소스 데이터와 참조 테이블을 연결하는 키 (예: customer_id → id) -
-
- ⚡ WHERE 조건: 참조 테이블에서 특정 조건의 데이터만 가져오기 (예: grade = 'VIP') -
-
- 📤 출력 필드: 참조 테이블에서 가져올 필드 선택 (별칭으로 결과에 추가됨) -
-
-
-
- ); -} diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 2ff31689..0cc77705 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -32,14 +32,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "source", color: "#10B981", // 초록색 }, - { - type: "referenceLookup", - label: "참조 조회", - icon: "", - description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)", - category: "source", - color: "#A855F7", // 보라색 - }, // ======================================================================== // 변환/조건 diff --git a/frontend/types/node-editor.ts b/frontend/types/node-editor.ts index fc5adb89..9b6ce969 100644 --- a/frontend/types/node-editor.ts +++ b/frontend/types/node-editor.ts @@ -12,7 +12,6 @@ export type NodeType = | "tableSource" // 테이블 소스 | "externalDBSource" // 외부 DB 소스 | "restAPISource" // REST API 소스 - | "referenceLookup" // 참조 테이블 조회 (내부 DB 전용) | "condition" // 조건 분기 | "dataTransform" // 데이터 변환 | "aggregate" // 집계 노드 (SUM, COUNT, AVG 등) @@ -92,35 +91,6 @@ export interface RestAPISourceNodeData { displayName?: string; } -// 참조 테이블 조회 노드 (내부 DB 전용) -export interface ReferenceLookupNodeData { - type: "referenceLookup"; - referenceTable: string; // 참조할 테이블명 - referenceTableLabel?: string; // 테이블 라벨 - joinConditions: Array<{ - // 조인 조건 (FK 매핑) - sourceField: string; // 소스 데이터의 필드 - sourceFieldLabel?: string; - referenceField: string; // 참조 테이블의 필드 - referenceFieldLabel?: string; - }>; - whereConditions?: Array<{ - // 추가 WHERE 조건 - field: string; - fieldLabel?: string; - operator: string; - value: any; - valueType?: "static" | "field"; // 고정값 또는 소스 필드 참조 - }>; - outputFields: Array<{ - // 가져올 필드들 - fieldName: string; // 참조 테이블의 컬럼명 - fieldLabel?: string; - alias: string; // 결과 데이터에서 사용할 이름 - }>; - displayName?: string; -} - // 조건 분기 노드 export interface ConditionNodeData { conditions: Array<{ @@ -431,7 +401,6 @@ export type NodeData = | TableSourceNodeData | ExternalDBSourceNodeData | RestAPISourceNodeData - | ReferenceLookupNodeData | ConditionNodeData | FieldMappingNodeData | DataTransformNodeData From 0aaab453297116ee1e2ec25d6d9e6eb0ae3690fb Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 9 Dec 2025 11:15:18 +0900 Subject: [PATCH 06/35] =?UTF-8?q?flowExecutionService=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=B6=94=EC=A0=81=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/flowDataMoveService.ts | 13 ++ .../src/services/flowExecutionService.ts | 76 +++++--- .../src/services/nodeFlowExecutionService.ts | 6 + .../components/flow/FlowDataListModal.tsx | 8 +- .../components/screen/widgets/FlowWidget.tsx | 176 +++++++++--------- 5 files changed, 156 insertions(+), 123 deletions(-) diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 39ab6013..09058502 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -72,6 +72,11 @@ export class FlowDataMoveService { // 내부 DB 처리 (기존 로직) return await db.transaction(async (client) => { try { + // 트랜잭션 세션 변수 설정 (트리거에서 changed_by 기록용) + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId || "system", + ]); + // 1. 단계 정보 조회 const fromStep = await this.flowStepService.findById(fromStepId); const toStep = await this.flowStepService.findById(toStepId); @@ -684,6 +689,14 @@ export class FlowDataMoveService { dbConnectionId, async (externalClient, dbType) => { try { + // 외부 DB가 PostgreSQL인 경우에만 세션 변수 설정 시도 + if (dbType.toLowerCase() === "postgresql") { + await externalClient.query( + "SELECT set_config('app.user_id', $1, true)", + [userId || "system"] + ); + } + // 1. 단계 정보 조회 (내부 DB에서) const fromStep = await this.flowStepService.findById(fromStepId); const toStep = await this.flowStepService.findById(toStepId); diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index dcaafb5b..bbabb935 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -298,7 +298,9 @@ export class FlowExecutionService { // 4. Primary Key 컬럼 결정 (기본값: id) const primaryKeyColumn = flowDef.primaryKey || "id"; - console.log(`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`); + console.log( + `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` + ); // 5. SET 절 생성 const updateColumns = Object.keys(updateData); @@ -309,74 +311,86 @@ export class FlowExecutionService { // 6. 외부 DB vs 내부 DB 구분 if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { // 외부 DB 업데이트 - console.log("✅ [updateStepData] Using EXTERNAL DB:", flowDef.dbConnectionId); - + console.log( + "✅ [updateStepData] Using EXTERNAL DB:", + flowDef.dbConnectionId + ); + // 외부 DB 연결 정보 조회 const connectionResult = await db.query( "SELECT * FROM external_db_connection WHERE id = $1", [flowDef.dbConnectionId] ); - + if (connectionResult.length === 0) { - throw new Error(`External DB connection not found: ${flowDef.dbConnectionId}`); + throw new Error( + `External DB connection not found: ${flowDef.dbConnectionId}` + ); } - + const connection = connectionResult[0]; const dbType = connection.db_type?.toLowerCase(); - + // DB 타입에 따른 placeholder 및 쿼리 생성 let setClause: string; let params: any[]; - + if (dbType === "mysql" || dbType === "mariadb") { // MySQL/MariaDB: ? placeholder setClause = updateColumns.map((col) => `\`${col}\` = ?`).join(", "); params = [...Object.values(updateData), recordId]; } else if (dbType === "mssql") { // MSSQL: @p1, @p2 placeholder - setClause = updateColumns.map((col, idx) => `[${col}] = @p${idx + 1}`).join(", "); + setClause = updateColumns + .map((col, idx) => `[${col}] = @p${idx + 1}`) + .join(", "); params = [...Object.values(updateData), recordId]; } else { // PostgreSQL: $1, $2 placeholder - setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); + setClause = updateColumns + .map((col, idx) => `"${col}" = $${idx + 1}`) + .join(", "); params = [...Object.values(updateData), recordId]; } - + const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = ${dbType === "mysql" || dbType === "mariadb" ? "?" : dbType === "mssql" ? `@p${params.length}` : `$${params.length}`}`; - + console.log(`📝 [updateStepData] Query: ${updateQuery}`); console.log(`📝 [updateStepData] Params:`, params); - + await executeExternalQuery(flowDef.dbConnectionId, updateQuery, params); } else { // 내부 DB 업데이트 console.log("✅ [updateStepData] Using INTERNAL DB"); - - const setClause = updateColumns.map((col, idx) => `"${col}" = $${idx + 1}`).join(", "); + + const setClause = updateColumns + .map((col, idx) => `"${col}" = $${idx + 1}`) + .join(", "); const params = [...Object.values(updateData), recordId]; - + const updateQuery = `UPDATE "${tableName}" SET ${setClause} WHERE "${primaryKeyColumn}" = $${params.length}`; - + console.log(`📝 [updateStepData] Query: ${updateQuery}`); console.log(`📝 [updateStepData] Params:`, params); - + // 트랜잭션으로 감싸서 사용자 ID 세션 변수 설정 후 업데이트 실행 // (트리거에서 changed_by를 기록하기 위함) - await db.query("BEGIN"); - try { - await db.query(`SET LOCAL app.user_id = '${userId}'`); - await db.query(updateQuery, params); - await db.query("COMMIT"); - } catch (txError) { - await db.query("ROLLBACK"); - throw txError; - } + await db.transaction(async (client) => { + // 안전한 파라미터 바인딩 방식 사용 + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId, + ]); + await client.query(updateQuery, params); + }); } - console.log(`✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, { - updatedFields: updateColumns, - userId, - }); + console.log( + `✅ [updateStepData] Data updated successfully: ${tableName}.${primaryKeyColumn}=${recordId}`, + { + updatedFields: updateColumns, + userId, + } + ); return { success: true }; } catch (error: any) { diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 2abcb04c..8a4fca31 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -175,6 +175,12 @@ export class NodeFlowExecutionService { try { result = await transaction(async (client) => { + // 🔥 사용자 ID 세션 변수 설정 (트리거용) + const userId = context.buttonContext?.userId || "system"; + await client.query("SELECT set_config('app.user_id', $1, true)", [ + userId, + ]); + // 트랜잭션 내에서 레벨별 실행 for (const level of levels) { await this.executeLevel(level, nodes, edges, context, client); diff --git a/frontend/components/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 352860e5..61264ffb 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogDescription } from "@/components/ui/resizable-dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; @@ -130,11 +130,11 @@ export function FlowDataListModal({ - + {stepName} {data.length}건 - - 이 단계에 해당하는 데이터 목록입니다 + + 이 단계에 해당하는 데이터 목록입니다
diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index a4d2dad9..50ad0343 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -1669,53 +1669,53 @@ export function FlowWidget({ > 검색 - - )} + + )}
- - {/* 필터/그룹 설정 버튼 */} + + {/* 필터/그룹 설정 버튼 */}
- + )} + -
+
{/* 새로고침 */}
@@ -1731,7 +1731,7 @@ export function FlowWidget({ 새로고침
-
+
{/* 검색 필터 입력 영역 */} {searchFilterColumns.size > 0 && ( @@ -1859,20 +1859,20 @@ export function FlowWidget({ {groupByColumns.length > 0 && groupedData.length > 0 ? ( // 그룹화된 렌더링 (기존 방식 유지)
-
+
- - {allowDataMove && ( + + {allowDataMove && ( - 0} - onCheckedChange={toggleAllRows} - /> - - )} - {stepDataColumns.map((col) => ( - 0} + onCheckedChange={toggleAllRows} + /> + + )} + {stepDataColumns.map((col) => ( + handleSort(col)} > @@ -1884,68 +1884,68 @@ export function FlowWidget({ )} - - ))} - - - + + ))} + + + {groupedData.flatMap((group) => { - const isCollapsed = collapsedGroups.has(group.groupKey); - const groupRows = [ - - + +
toggleGroupCollapse(group.groupKey)} > -
toggleGroupCollapse(group.groupKey)} - > - {isCollapsed ? ( - - ) : ( - - )} - {group.groupKey} - ({group.count}건) -
- - , - ]; + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
+
+
, + ]; - if (!isCollapsed) { - const dataRows = group.items.map((row, itemIndex) => { + if (!isCollapsed) { + const dataRows = group.items.map((row, itemIndex) => { const actualIndex = sortedDisplayData.indexOf(row); - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> - - )} - {stepDataColumns.map((col) => ( - - {formatValue(row[col])} - - ))} - - ); - }); - groupRows.push(...dataRows); - } + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }); + groupRows.push(...dataRows); + } - return groupRows; + return groupRows; })}
- ) : ( + ) : ( // 일반 렌더링 - SingleTableWithSticky 사용 Date: Tue, 9 Dec 2025 12:05:12 +0900 Subject: [PATCH 07/35] =?UTF-8?q?UTC=20DB=20=ED=99=98=EA=B2=BD=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=209=EC=8B=9C=EA=B0=84=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/TableHistoryModal.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index a40c1211..6e552e21 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -131,8 +131,21 @@ export function TableHistoryModal({ const formatDate = (dateString: string) => { try { - // DB는 UTC로 저장, 브라우저가 자동으로 로컬 시간(KST)으로 변환 const date = new Date(dateString); + + // 🚨 타임존 보정 로직 + // 실 서비스 DB는 UTC로 저장되는데, 프론트엔드에서 이를 KST로 인식하지 못하고 + // UTC 시간 그대로(예: 02:55)를 한국 시간 02:55로 보여주는 문제가 있음 (9시간 느림). + // 반면 로컬 DB는 이미 KST로 저장되어 있어서 변환하면 안 됨. + // 따라서 로컬 환경이 아닐 때만 강제로 9시간을 더해줌. + const isLocal = + typeof window !== "undefined" && + (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); + + if (!isLocal) { + date.setHours(date.getHours() + 9); + } + return format(date, "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); } catch { return dateString; From bb98e9319f474356b906deb511fdf0fdb113f35b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 9 Dec 2025 12:13:30 +0900 Subject: [PATCH 08/35] =?UTF-8?q?=EC=99=B8=EB=B6=80=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/nodeFlowExecutionService.ts | 471 ++++++++++++++ .../dataflow/node-editor/FlowEditor.tsx | 54 +- .../node-editor/nodes/EmailActionNode.tsx | 103 ++++ .../nodes/HttpRequestActionNode.tsx | 124 ++++ .../node-editor/nodes/ScriptActionNode.tsx | 118 ++++ .../node-editor/panels/PropertiesPanel.tsx | 15 + .../properties/EmailActionProperties.tsx | 431 +++++++++++++ .../HttpRequestActionProperties.tsx | 568 +++++++++++++++++ .../properties/ScriptActionProperties.tsx | 575 ++++++++++++++++++ .../node-editor/sidebar/nodePaletteConfig.ts | 35 +- frontend/lib/stores/flowEditorStore.ts | 10 +- frontend/types/node-editor.ts | 172 +++++- 12 files changed, 2671 insertions(+), 5 deletions(-) create mode 100644 frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/HttpRequestActionProperties.tsx create mode 100644 frontend/components/dataflow/node-editor/panels/properties/ScriptActionProperties.tsx diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 2abcb04c..0542b51e 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -32,6 +32,9 @@ export type NodeType = | "updateAction" | "deleteAction" | "upsertAction" + | "emailAction" // 이메일 발송 액션 + | "scriptAction" // 스크립트 실행 액션 + | "httpRequestAction" // HTTP 요청 액션 | "comment" | "log"; @@ -547,6 +550,15 @@ export class NodeFlowExecutionService { case "condition": return this.executeCondition(node, inputData, context); + case "emailAction": + return this.executeEmailAction(node, inputData, context); + + case "scriptAction": + return this.executeScriptAction(node, inputData, context); + + case "httpRequestAction": + return this.executeHttpRequestAction(node, inputData, context); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -3379,4 +3391,463 @@ export class NodeFlowExecutionService { return filteredResults; } + + // =================================================================== + // 외부 연동 액션 노드들 + // =================================================================== + + /** + * 이메일 발송 액션 노드 실행 + */ + private static async executeEmailAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + from, + to, + cc, + bcc, + subject, + body, + bodyType, + isHtml, // 레거시 지원 + accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID + smtpConfigId, // 레거시 지원 + attachments, + templateVariables, + } = node.data; + + logger.info(`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`); + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + // 동적 임포트로 순환 참조 방지 + const { mailSendSimpleService } = await import("./mailSendSimpleService"); + const { mailAccountFileService } = await import("./mailAccountFileService"); + + // 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정 + let accountId = nodeAccountId || smtpConfigId; + if (!accountId) { + const accounts = await mailAccountFileService.getAccounts(); + const activeAccount = accounts.find((acc: any) => acc.status === "active"); + if (activeAccount) { + accountId = activeAccount.id; + logger.info(`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`); + } else { + throw new Error("활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요."); + } + } + + // HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원) + const useHtml = bodyType === "html" || isHtml === true; + + for (const data of dataArray) { + try { + // 템플릿 변수 치환 + const processedSubject = this.replaceTemplateVariables(subject || "", data); + const processedBody = this.replaceTemplateVariables(body || "", data); + const processedTo = this.replaceTemplateVariables(to || "", data); + const processedCc = cc ? this.replaceTemplateVariables(cc, data) : undefined; + const processedBcc = bcc ? this.replaceTemplateVariables(bcc, data) : undefined; + + // 수신자 파싱 (쉼표로 구분) + const toList = processedTo.split(",").map((email: string) => email.trim()).filter((email: string) => email); + const ccList = processedCc ? processedCc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined; + const bccList = processedBcc ? processedBcc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined; + + if (toList.length === 0) { + throw new Error("수신자 이메일 주소가 지정되지 않았습니다."); + } + + // 메일 발송 요청 + const sendResult = await mailSendSimpleService.sendMail({ + accountId, + to: toList, + cc: ccList, + bcc: bccList, + subject: processedSubject, + customHtml: useHtml ? processedBody : `
${processedBody}
`, + attachments: attachments?.map((att: any) => ({ + filename: att.type === "dataField" ? data[att.value] : att.value, + path: att.type === "dataField" ? data[att.value] : att.value, + })), + }); + + if (sendResult.success) { + logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`); + results.push({ + success: true, + to: toList, + messageId: sendResult.messageId, + }); + } else { + logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`); + results.push({ + success: false, + to: toList, + error: sendResult.error, + }); + } + } catch (error: any) { + logger.error(`❌ 이메일 발송 오류:`, error); + results.push({ + success: false, + error: error.message, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "emailAction", + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * 스크립트 실행 액션 노드 실행 + */ + private static async executeScriptAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + scriptType, + scriptPath, + arguments: scriptArgs, + workingDirectory, + environmentVariables, + timeout, + captureOutput, + } = node.data; + + logger.info(`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`); + + if (!scriptPath) { + throw new Error("스크립트 경로가 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + // child_process 모듈 동적 임포트 + const { spawn } = await import("child_process"); + const path = await import("path"); + + for (const data of dataArray) { + try { + // 인자 처리 + const processedArgs: string[] = []; + if (scriptArgs && Array.isArray(scriptArgs)) { + for (const arg of scriptArgs) { + if (arg.type === "dataField") { + // 데이터 필드 참조 + const value = this.replaceTemplateVariables(arg.value, data); + processedArgs.push(value); + } else { + processedArgs.push(arg.value); + } + } + } + + // 환경 변수 처리 + const env = { + ...process.env, + ...(environmentVariables || {}), + }; + + // 스크립트 타입에 따른 명령어 결정 + let command: string; + let args: string[]; + + switch (scriptType) { + case "python": + command = "python3"; + args = [scriptPath, ...processedArgs]; + break; + case "shell": + command = "bash"; + args = [scriptPath, ...processedArgs]; + break; + case "executable": + command = scriptPath; + args = processedArgs; + break; + default: + throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`); + } + + logger.info(` 실행 명령: ${command} ${args.join(" ")}`); + + // 스크립트 실행 (Promise로 래핑) + const result = await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd: workingDirectory || process.cwd(), + env, + timeout: timeout || 60000, // 기본 60초 + }); + + let stdout = ""; + let stderr = ""; + + if (captureOutput !== false) { + childProcess.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + childProcess.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + } + + childProcess.on("close", (code) => { + resolve({ exitCode: code, stdout, stderr }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); + + if (result.exitCode === 0) { + logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`); + results.push({ + success: true, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } else { + logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`); + results.push({ + success: false, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + data, + }); + } + } catch (error: any) { + logger.error(`❌ 스크립트 실행 오류:`, error); + results.push({ + success: false, + error: error.message, + data, + }); + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "scriptAction", + scriptType, + scriptPath, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } + + /** + * HTTP 요청 액션 노드 실행 + */ + private static async executeHttpRequestAction( + node: FlowNode, + inputData: any, + context: ExecutionContext + ): Promise { + const { + url, + method, + headers, + bodyTemplate, + bodyType, + authentication, + timeout, + retryCount, + responseMapping, + } = node.data; + + logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`); + logger.info(` 메서드: ${method}, URL: ${url}`); + + if (!url) { + throw new Error("HTTP 요청 URL이 지정되지 않았습니다."); + } + + // 입력 데이터를 배열로 정규화 + const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}]; + const results: any[] = []; + + for (const data of dataArray) { + let currentRetry = 0; + const maxRetries = retryCount || 0; + + while (currentRetry <= maxRetries) { + try { + // URL 템플릿 변수 치환 + const processedUrl = this.replaceTemplateVariables(url, data); + + // 헤더 처리 + const processedHeaders: Record = {}; + if (headers && Array.isArray(headers)) { + for (const header of headers) { + const headerValue = + header.valueType === "dataField" + ? this.replaceTemplateVariables(header.value, data) + : header.value; + processedHeaders[header.name] = headerValue; + } + } + + // 인증 헤더 추가 + if (authentication) { + switch (authentication.type) { + case "basic": + if (authentication.username && authentication.password) { + const credentials = Buffer.from( + `${authentication.username}:${authentication.password}` + ).toString("base64"); + processedHeaders["Authorization"] = `Basic ${credentials}`; + } + break; + case "bearer": + if (authentication.token) { + processedHeaders["Authorization"] = `Bearer ${authentication.token}`; + } + break; + case "apikey": + if (authentication.apiKey) { + if (authentication.apiKeyLocation === "query") { + // 쿼리 파라미터로 추가 (URL에 추가) + const paramName = authentication.apiKeyQueryParam || "api_key"; + const separator = processedUrl.includes("?") ? "&" : "?"; + // URL은 이미 처리되었으므로 여기서는 결과에 포함 + } else { + // 헤더로 추가 + const headerName = authentication.apiKeyHeader || "X-API-Key"; + processedHeaders[headerName] = authentication.apiKey; + } + } + break; + } + } + + // Content-Type 기본값 + if (!processedHeaders["Content-Type"] && ["POST", "PUT", "PATCH"].includes(method)) { + processedHeaders["Content-Type"] = + bodyType === "json" ? "application/json" : "text/plain"; + } + + // 바디 처리 + let processedBody: string | undefined; + if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) { + processedBody = this.replaceTemplateVariables(bodyTemplate, data); + } + + logger.info(` 요청 URL: ${processedUrl}`); + logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`); + if (processedBody) { + logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`); + } + + // HTTP 요청 실행 + const response = await axios({ + method: method.toLowerCase() as any, + url: processedUrl, + headers: processedHeaders, + data: processedBody, + timeout: timeout || 30000, + validateStatus: () => true, // 모든 상태 코드 허용 + }); + + logger.info(` 응답 상태: ${response.status} ${response.statusText}`); + + // 응답 데이터 처리 + let responseData = response.data; + + // 응답 매핑 적용 + if (responseMapping && responseData) { + const paths = responseMapping.split("."); + for (const path of paths) { + if (responseData && typeof responseData === "object" && path in responseData) { + responseData = responseData[path]; + } else { + logger.warn(`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`); + break; + } + } + } + + const isSuccess = response.status >= 200 && response.status < 300; + + if (isSuccess) { + logger.info(`✅ HTTP 요청 성공`); + results.push({ + success: true, + statusCode: response.status, + data: responseData, + inputData: data, + }); + break; // 성공 시 재시도 루프 종료 + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error: any) { + currentRetry++; + if (currentRetry > maxRetries) { + logger.error(`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, error.message); + results.push({ + success: false, + error: error.message, + inputData: data, + }); + } else { + logger.warn(`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`); + // 재시도 전 잠시 대기 + await new Promise((resolve) => setTimeout(resolve, 1000 * currentRetry)); + } + } + } + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.filter((r) => !r.success).length; + + logger.info(`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}건`); + + return { + action: "httpRequestAction", + method, + url, + totalCount: results.length, + successCount, + failedCount, + results, + }; + } } diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 333a70c1..81282c0b 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -28,6 +28,9 @@ import { AggregateNode } from "./nodes/AggregateNode"; import { RestAPISourceNode } from "./nodes/RestAPISourceNode"; import { CommentNode } from "./nodes/CommentNode"; import { LogNode } from "./nodes/LogNode"; +import { EmailActionNode } from "./nodes/EmailActionNode"; +import { ScriptActionNode } from "./nodes/ScriptActionNode"; +import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode"; import { validateFlow } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation"; @@ -41,11 +44,15 @@ const nodeTypes = { condition: ConditionNode, dataTransform: DataTransformNode, aggregate: AggregateNode, - // 액션 + // 데이터 액션 insertAction: InsertActionNode, updateAction: UpdateActionNode, deleteAction: DeleteActionNode, upsertAction: UpsertActionNode, + // 외부 연동 액션 + emailAction: EmailActionNode, + scriptAction: ScriptActionNode, + httpRequestAction: HttpRequestActionNode, // 유틸리티 comment: CommentNode, log: LogNode, @@ -246,7 +253,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { defaultData.responseMapping = ""; } - // 액션 노드의 경우 targetType 기본값 설정 + // 데이터 액션 노드의 경우 targetType 기본값 설정 if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) { defaultData.targetType = "internal"; // 기본값: 내부 DB defaultData.fieldMappings = []; @@ -261,6 +268,49 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) { } } + // 메일 발송 노드 + if (type === "emailAction") { + defaultData.displayName = "메일 발송"; + defaultData.smtpConfig = { + host: "", + port: 587, + secure: false, + }; + defaultData.from = ""; + defaultData.to = ""; + defaultData.subject = ""; + defaultData.body = ""; + defaultData.bodyType = "text"; + } + + // 스크립트 실행 노드 + if (type === "scriptAction") { + defaultData.displayName = "스크립트 실행"; + defaultData.scriptType = "python"; + defaultData.executionMode = "inline"; + defaultData.inlineScript = ""; + defaultData.inputMethod = "stdin"; + defaultData.inputFormat = "json"; + defaultData.outputHandling = { + captureStdout: true, + captureStderr: true, + parseOutput: "text", + }; + } + + // HTTP 요청 노드 + if (type === "httpRequestAction") { + defaultData.displayName = "HTTP 요청"; + defaultData.url = ""; + defaultData.method = "GET"; + defaultData.bodyType = "none"; + defaultData.authentication = { type: "none" }; + defaultData.options = { + timeout: 30000, + followRedirects: true, + }; + } + const newNode: any = { id: `node_${Date.now()}`, type, diff --git a/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx new file mode 100644 index 00000000..ea8e05dc --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/EmailActionNode.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * 메일 발송 액션 노드 + * SMTP를 통해 이메일을 발송하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Mail, Server } from "lucide-react"; +import type { EmailActionNodeData } from "@/types/node-editor"; + +export const EmailActionNode = memo(({ data, selected }: NodeProps) => { + const hasSmtpConfig = data.smtpConfig?.host && data.smtpConfig?.port; + const hasRecipient = data.to && data.to.trim().length > 0; + const hasSubject = data.subject && data.subject.trim().length > 0; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "메일 발송"}
+
+
+ + {/* 본문 */} +
+ {/* SMTP 설정 상태 */} +
+ + + {hasSmtpConfig ? ( + + {data.smtpConfig.host}:{data.smtpConfig.port} + + ) : ( + SMTP 설정 필요 + )} + +
+ + {/* 수신자 */} +
+ 수신자: + {hasRecipient ? ( + {data.to} + ) : ( + 미설정 + )} +
+ + {/* 제목 */} +
+ 제목: + {hasSubject ? ( + {data.subject} + ) : ( + 미설정 + )} +
+ + {/* 본문 형식 */} +
+ + {data.bodyType === "html" ? "HTML" : "TEXT"} + + {data.attachments && data.attachments.length > 0 && ( + + 첨부 {data.attachments.length}개 + + )} +
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +EmailActionNode.displayName = "EmailActionNode"; + diff --git a/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx new file mode 100644 index 00000000..25677933 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/HttpRequestActionNode.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * HTTP 요청 액션 노드 + * REST API를 호출하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Globe, Lock, Unlock } from "lucide-react"; +import type { HttpRequestActionNodeData } from "@/types/node-editor"; + +// HTTP 메서드별 색상 +const METHOD_COLORS: Record = { + GET: { bg: "bg-green-100", text: "text-green-700" }, + POST: { bg: "bg-blue-100", text: "text-blue-700" }, + PUT: { bg: "bg-orange-100", text: "text-orange-700" }, + PATCH: { bg: "bg-yellow-100", text: "text-yellow-700" }, + DELETE: { bg: "bg-red-100", text: "text-red-700" }, + HEAD: { bg: "bg-gray-100", text: "text-gray-700" }, + OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" }, +}; + +export const HttpRequestActionNode = memo(({ data, selected }: NodeProps) => { + const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET; + const hasUrl = data.url && data.url.trim().length > 0; + const hasAuth = data.authentication?.type && data.authentication.type !== "none"; + + // URL에서 도메인 추출 + const getDomain = (url: string) => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } + }; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "HTTP 요청"}
+
+
+ + {/* 본문 */} +
+ {/* 메서드 & 인증 */} +
+ + {data.method} + + {hasAuth ? ( + + + {data.authentication?.type} + + ) : ( + + + 인증없음 + + )} +
+ + {/* URL */} +
+ URL: + {hasUrl ? ( + + {getDomain(data.url)} + + ) : ( + URL 설정 필요 + )} +
+ + {/* 바디 타입 */} + {data.bodyType && data.bodyType !== "none" && ( +
+ Body: + + {data.bodyType.toUpperCase()} + +
+ )} + + {/* 타임아웃 & 재시도 */} +
+ {data.options?.timeout && ( + 타임아웃: {Math.round(data.options.timeout / 1000)}초 + )} + {data.options?.retryCount && data.options.retryCount > 0 && ( + 재시도: {data.options.retryCount}회 + )} +
+
+ + {/* 출력 핸들 */} + +
+ ); +}); + +HttpRequestActionNode.displayName = "HttpRequestActionNode"; + diff --git a/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx new file mode 100644 index 00000000..c4027047 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ScriptActionNode.tsx @@ -0,0 +1,118 @@ +"use client"; + +/** + * 스크립트 실행 액션 노드 + * Python, Shell, PowerShell 등 외부 스크립트를 실행하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Terminal, FileCode, Play } from "lucide-react"; +import type { ScriptActionNodeData } from "@/types/node-editor"; + +// 스크립트 타입별 아이콘 색상 +const SCRIPT_TYPE_COLORS: Record = { + python: { bg: "bg-yellow-100", text: "text-yellow-700", label: "Python" }, + shell: { bg: "bg-green-100", text: "text-green-700", label: "Shell" }, + powershell: { bg: "bg-blue-100", text: "text-blue-700", label: "PowerShell" }, + node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" }, + executable: { bg: "bg-gray-100", text: "text-gray-700", label: "실행파일" }, +}; + +export const ScriptActionNode = memo(({ data, selected }: NodeProps) => { + const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable; + const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
{data.displayName || "스크립트 실행"}
+
+
+ + {/* 본문 */} +
+ {/* 스크립트 타입 */} +
+ + {scriptTypeInfo.label} + + + {data.executionMode === "inline" ? "인라인" : "파일"} + +
+ + {/* 스크립트 정보 */} +
+ {data.executionMode === "inline" ? ( + <> + + + {hasScript ? ( + + {data.inlineScript!.split("\n").length}줄 스크립트 + + ) : ( + 스크립트 입력 필요 + )} + + + ) : ( + <> + + + {hasScript ? ( + {data.scriptPath} + ) : ( + 파일 경로 필요 + )} + + + )} +
+ + {/* 입력 방식 */} +
+ 입력: + + {data.inputMethod === "stdin" && "표준입력 (stdin)"} + {data.inputMethod === "args" && "명령줄 인자"} + {data.inputMethod === "env" && "환경변수"} + {data.inputMethod === "file" && "파일"} + +
+ + {/* 타임아웃 */} + {data.options?.timeout && ( +
+ 타임아웃: {Math.round(data.options.timeout / 1000)}초 +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); +}); + +ScriptActionNode.displayName = "ScriptActionNode"; + diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index 67483e03..41f1a9b4 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -19,6 +19,9 @@ import { AggregateProperties } from "./properties/AggregateProperties"; import { RestAPISourceProperties } from "./properties/RestAPISourceProperties"; import { CommentProperties } from "./properties/CommentProperties"; import { LogProperties } from "./properties/LogProperties"; +import { EmailActionProperties } from "./properties/EmailActionProperties"; +import { ScriptActionProperties } from "./properties/ScriptActionProperties"; +import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties"; import type { NodeType } from "@/types/node-editor"; export function PropertiesPanel() { @@ -131,6 +134,15 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "log": return ; + case "emailAction": + return ; + + case "scriptAction": + return ; + + case "httpRequestAction": + return ; + default: return (
@@ -165,6 +177,9 @@ function getNodeTypeLabel(type: NodeType): string { updateAction: "UPDATE 액션", deleteAction: "DELETE 액션", upsertAction: "UPSERT 액션", + emailAction: "메일 발송", + scriptAction: "스크립트 실행", + httpRequestAction: "HTTP 요청", comment: "주석", log: "로그", }; diff --git a/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx new file mode 100644 index 00000000..b57ba029 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/EmailActionProperties.tsx @@ -0,0 +1,431 @@ +"use client"; + +/** + * 메일 발송 노드 속성 편집 + * - 메일관리에서 등록한 계정을 선택하여 발송 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, AlertCircle, User } from "lucide-react"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { getMailAccounts, type MailAccount } from "@/lib/api/mail"; +import type { EmailActionNodeData } from "@/types/node-editor"; + +interface EmailActionPropertiesProps { + nodeId: string; + data: EmailActionNodeData; +} + +export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) { + const { updateNode } = useFlowEditorStore(); + + // 메일 계정 목록 + const [mailAccounts, setMailAccounts] = useState([]); + const [isLoadingAccounts, setIsLoadingAccounts] = useState(false); + const [accountError, setAccountError] = useState(null); + + // 로컬 상태 + const [displayName, setDisplayName] = useState(data.displayName || "메일 발송"); + + // 계정 선택 + const [selectedAccountId, setSelectedAccountId] = useState(data.accountId || ""); + + // 메일 내용 + const [to, setTo] = useState(data.to || ""); + const [cc, setCc] = useState(data.cc || ""); + const [bcc, setBcc] = useState(data.bcc || ""); + const [subject, setSubject] = useState(data.subject || ""); + const [body, setBody] = useState(data.body || ""); + const [bodyType, setBodyType] = useState<"text" | "html">(data.bodyType || "text"); + + // 고급 설정 + const [replyTo, setReplyTo] = useState(data.replyTo || ""); + const [priority, setPriority] = useState<"high" | "normal" | "low">(data.priority || "normal"); + const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000"); + const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "3"); + + // 메일 계정 목록 로드 + const loadMailAccounts = useCallback(async () => { + setIsLoadingAccounts(true); + setAccountError(null); + try { + const accounts = await getMailAccounts(); + setMailAccounts(accounts.filter(acc => acc.status === 'active')); + } catch (error) { + console.error("메일 계정 로드 실패:", error); + setAccountError("메일 계정을 불러오는데 실패했습니다"); + } finally { + setIsLoadingAccounts(false); + } + }, []); + + // 컴포넌트 마운트 시 메일 계정 로드 + useEffect(() => { + loadMailAccounts(); + }, [loadMailAccounts]); + + // 데이터 변경 시 로컬 상태 동기화 + useEffect(() => { + setDisplayName(data.displayName || "메일 발송"); + setSelectedAccountId(data.accountId || ""); + setTo(data.to || ""); + setCc(data.cc || ""); + setBcc(data.bcc || ""); + setSubject(data.subject || ""); + setBody(data.body || ""); + setBodyType(data.bodyType || "text"); + setReplyTo(data.replyTo || ""); + setPriority(data.priority || "normal"); + setTimeout(data.options?.timeout?.toString() || "30000"); + setRetryCount(data.options?.retryCount?.toString() || "3"); + }, [data]); + + // 선택된 계정 정보 + const selectedAccount = mailAccounts.find(acc => acc.id === selectedAccountId); + + // 노드 업데이트 함수 + const updateNodeData = useCallback( + (updates: Partial) => { + updateNode(nodeId, { + ...data, + ...updates, + }); + }, + [nodeId, data, updateNode] + ); + + // 표시명 변경 + const handleDisplayNameChange = (value: string) => { + setDisplayName(value); + updateNodeData({ displayName: value }); + }; + + // 계정 선택 변경 + const handleAccountChange = (accountId: string) => { + setSelectedAccountId(accountId); + const account = mailAccounts.find(acc => acc.id === accountId); + updateNodeData({ + accountId, + // 계정의 이메일을 발신자로 자동 설정 + from: account?.email || "" + }); + }; + + // 메일 내용 업데이트 + const updateMailContent = useCallback(() => { + updateNodeData({ + to, + cc: cc || undefined, + bcc: bcc || undefined, + subject, + body, + bodyType, + replyTo: replyTo || undefined, + priority, + }); + }, [to, cc, bcc, subject, body, bodyType, replyTo, priority, updateNodeData]); + + // 옵션 업데이트 + const updateOptions = useCallback(() => { + updateNodeData({ + options: { + timeout: parseInt(timeout) || 30000, + retryCount: parseInt(retryCount) || 3, + }, + }); + }, [timeout, retryCount, updateNodeData]); + + return ( +
+ {/* 표시명 */} +
+ + handleDisplayNameChange(e.target.value)} + placeholder="메일 발송" + className="h-8 text-sm" + /> +
+ + + + + + 계정 + + + + 메일 + + + + 본문 + + + + 옵션 + + + + {/* 계정 선택 탭 */} + +
+
+ + +
+ + {accountError && ( +
+ + {accountError} +
+ )} + + +
+ + {/* 선택된 계정 정보 표시 */} + {selectedAccount && ( + + +
+ + 선택된 계정 +
+
+
이름: {selectedAccount.name}
+
이메일: {selectedAccount.email}
+
SMTP: {selectedAccount.smtpHost}:{selectedAccount.smtpPort}
+
+
+
+ )} + + {!selectedAccount && mailAccounts.length > 0 && ( + + +
+ + 메일 발송을 위해 계정을 선택해주세요. +
+
+
+ )} + + {mailAccounts.length === 0 && !isLoadingAccounts && ( + + +
+
메일 계정 등록 방법:
+
    +
  1. 관리자 메뉴로 이동
  2. +
  3. 메일관리 > 계정관리 선택
  4. +
  5. 새 계정 추가 버튼 클릭
  6. +
  7. SMTP 정보 입력 후 저장
  8. +
+
+
+
+ )} +
+ + {/* 메일 설정 탭 */} + + {/* 발신자는 선택된 계정에서 자동으로 설정됨 */} + {selectedAccount && ( +
+ +
+ {selectedAccount.email} +
+

선택한 계정의 이메일 주소가 자동으로 사용됩니다.

+
+ )} + +
+ + setTo(e.target.value)} + onBlur={updateMailContent} + placeholder="recipient@example.com (쉼표로 구분)" + className="h-8 text-sm" + /> +
+ +
+ + setCc(e.target.value)} + onBlur={updateMailContent} + placeholder="cc@example.com" + className="h-8 text-sm" + /> +
+ +
+ + setBcc(e.target.value)} + onBlur={updateMailContent} + placeholder="bcc@example.com" + className="h-8 text-sm" + /> +
+ +
+ + setReplyTo(e.target.value)} + onBlur={updateMailContent} + placeholder="reply@example.com" + className="h-8 text-sm" + /> +
+ +
+ + +
+
+ + {/* 본문 탭 */} + +
+ + setSubject(e.target.value)} + onBlur={updateMailContent} + placeholder="메일 제목 ({{변수}} 사용 가능)" + className="h-8 text-sm" + /> +
+ +
+ + +
+ +
+ +