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/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) { diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 966842b8..dcaafb5b 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -263,4 +263,125 @@ 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); + + // 트랜잭션으로 감싸서 사용자 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}`, { + 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() + )} ); })}