From 2cc0a7b3091fa5e60f8b22c4e10bb361e4fd72ed Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 8 Dec 2025 10:34:37 +0900 Subject: [PATCH 01/25] =?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/25] =?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/25] =?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 a278ceca3fecd0f0d514239761015615abc744a9 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 17:54:11 +0900 Subject: [PATCH 04/25] =?UTF-8?q?feat(universal-form-modal):=20=EB=B2=94?= =?UTF-8?q?=EC=9A=A9=20=EB=8B=A4=EC=A4=91=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=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 --- .../controllers/tableManagementController.ts | 296 ++++ .../src/routes/tableManagementRoutes.ts | 14 + .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../SplitPanelLayout2ConfigPanel.tsx | 114 +- .../UniversalFormModalComponent.tsx | 276 ++-- .../UniversalFormModalConfigPanel.tsx | 1229 ++++++++++++----- .../components/universal-form-modal/types.ts | 97 +- 7 files changed, 1482 insertions(+), 547 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 4a80b007..ce6a73b9 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1811,3 +1811,299 @@ export async function getCategoryColumnsByMenu( }); } } + +/** + * 범용 다중 테이블 저장 API + * + * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. + * + * 요청 본문: + * { + * mainTable: { tableName: string, primaryKeyColumn: string }, + * mainData: Record, + * subTables: Array<{ + * tableName: string, + * linkColumn: { mainField: string, subColumn: string }, + * items: Record[], + * options?: { + * saveMainAsFirst?: boolean, + * mainFieldMappings?: Array<{ formField: string, targetColumn: string }>, + * mainMarkerColumn?: string, + * mainMarkerValue?: any, + * subMarkerValue?: any, + * deleteExistingBefore?: boolean, + * } + * }>, + * isUpdate?: boolean + * } + */ +export async function multiTableSave( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = require("../database/db").getPool(); + const client = await pool.connect(); + + try { + const { mainTable, mainData, subTables, isUpdate } = req.body; + const companyCode = req.user?.companyCode || "*"; + + logger.info("=== 다중 테이블 저장 시작 ===", { + mainTable, + mainDataKeys: Object.keys(mainData || {}), + subTablesCount: subTables?.length || 0, + isUpdate, + companyCode, + }); + + // 유효성 검사 + if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) { + res.status(400).json({ + success: false, + message: "메인 테이블 설정이 올바르지 않습니다.", + }); + return; + } + + if (!mainData || Object.keys(mainData).length === 0) { + res.status(400).json({ + success: false, + message: "저장할 메인 데이터가 없습니다.", + }); + return; + } + + await client.query("BEGIN"); + + // 1. 메인 테이블 저장 + const mainTableName = mainTable.tableName; + const pkColumn = mainTable.primaryKeyColumn; + const pkValue = mainData[pkColumn]; + + // company_code 자동 추가 (최고 관리자가 아닌 경우) + if (companyCode !== "*" && !mainData.company_code) { + mainData.company_code = companyCode; + } + + let mainResult: any; + + if (isUpdate && pkValue) { + // UPDATE + const updateColumns = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map((col, idx) => `"${col}" = $${idx + 1}`) + .join(", "); + const updateValues = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map(col => mainData[col]); + + // updated_at 컬럼 존재 여부 확인 + const hasUpdatedAt = await client.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'updated_at' + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + + const updateQuery = ` + UPDATE "${mainTableName}" + SET ${updateColumns}${updatedAtClause} + WHERE "${pkColumn}" = $${updateValues.length + 1} + ${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""} + RETURNING * + `; + + const updateParams = companyCode !== "*" + ? [...updateValues, pkValue, companyCode] + : [...updateValues, pkValue]; + + logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length }); + mainResult = await client.query(updateQuery, updateParams); + } else { + // INSERT + const columns = Object.keys(mainData).map(col => `"${col}"`).join(", "); + const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", "); + const values = Object.values(mainData); + + // updated_at 컬럼 존재 여부 확인 + const hasUpdatedAt = await client.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'updated_at' + `, [mainTableName]); + const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : ""; + + const updateSetClause = Object.keys(mainData) + .filter(col => col !== pkColumn) + .map(col => `"${col}" = EXCLUDED."${col}"`) + .join(", "); + + const insertQuery = ` + INSERT INTO "${mainTableName}" (${columns}) + VALUES (${placeholders}) + ON CONFLICT ("${pkColumn}") DO UPDATE SET + ${updateSetClause}${updatedAtClause} + RETURNING * + `; + + logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length }); + mainResult = await client.query(insertQuery, values); + } + + if (mainResult.rowCount === 0) { + throw new Error("메인 테이블 저장 실패"); + } + + const savedMainData = mainResult.rows[0]; + const savedPkValue = savedMainData[pkColumn]; + logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue }); + + // 2. 서브 테이블 저장 + const subTableResults: any[] = []; + + for (const subTableConfig of subTables || []) { + const { tableName, linkColumn, items, options } = subTableConfig; + + if (!tableName || !items || items.length === 0) { + logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음`); + continue; + } + + logger.info(`서브 테이블 ${tableName} 저장 시작:`, { + itemsCount: items.length, + linkColumn, + options, + }); + + // 기존 데이터 삭제 옵션 + if (options?.deleteExistingBefore && linkColumn?.subColumn) { + const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2` + : `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`; + + const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn + ? [savedPkValue, options.subMarkerValue ?? false] + : [savedPkValue]; + + logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams }); + await client.query(deleteQuery, deleteParams); + } + + // 메인 데이터도 서브 테이블에 저장 (옵션) + if (options?.saveMainAsFirst && options?.mainFieldMappings && linkColumn?.subColumn) { + const mainSubItem: Record = { + [linkColumn.subColumn]: savedPkValue, + }; + + // 메인 필드 매핑 적용 + for (const mapping of options.mainFieldMappings) { + if (mapping.formField && mapping.targetColumn) { + mainSubItem[mapping.targetColumn] = mainData[mapping.formField]; + } + } + + // 메인 마커 설정 + if (options.mainMarkerColumn) { + mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true; + } + + // company_code 추가 + if (companyCode !== "*") { + mainSubItem.company_code = companyCode; + } + + const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubValues = Object.values(mainSubItem); + + // UPSERT 쿼리 (PK가 있다면) + const mainSubInsertQuery = ` + INSERT INTO "${tableName}" (${mainSubColumns}) + VALUES (${mainSubPlaceholders}) + ON CONFLICT ("${linkColumn.subColumn}"${options.mainMarkerColumn ? `, "${options.mainMarkerColumn}"` : ""}) + DO UPDATE SET + ${Object.keys(mainSubItem) + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn) + .map(col => `"${col}" = EXCLUDED."${col}"`) + .join(", ") || "updated_at = NOW()"} + RETURNING * + `; + + try { + logger.info(`서브 테이블 ${tableName} 메인 데이터 저장:`, { mainSubInsertQuery, mainSubValues }); + const mainSubResult = await client.query(mainSubInsertQuery, mainSubValues); + subTableResults.push({ tableName, type: "main", data: mainSubResult.rows[0] }); + } catch (err: any) { + // ON CONFLICT 실패 시 일반 INSERT 시도 + logger.warn(`서브 테이블 ${tableName} UPSERT 실패, 일반 INSERT 시도:`, err.message); + const simpleInsertQuery = ` + INSERT INTO "${tableName}" (${mainSubColumns}) + VALUES (${mainSubPlaceholders}) + RETURNING * + `; + const simpleResult = await client.query(simpleInsertQuery, mainSubValues); + subTableResults.push({ tableName, type: "main", data: simpleResult.rows[0] }); + } + } + + // 서브 아이템들 저장 + for (const item of items) { + // 연결 컬럼 값 설정 + if (linkColumn?.subColumn) { + item[linkColumn.subColumn] = savedPkValue; + } + + // company_code 추가 + if (companyCode !== "*" && !item.company_code) { + item.company_code = companyCode; + } + + const subColumns = Object.keys(item).map(col => `"${col}"`).join(", "); + const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", "); + const subValues = Object.values(item); + + const subInsertQuery = ` + INSERT INTO "${tableName}" (${subColumns}) + VALUES (${subPlaceholders}) + RETURNING * + `; + + logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length }); + const subResult = await client.query(subInsertQuery, subValues); + subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] }); + } + + logger.info(`서브 테이블 ${tableName} 저장 완료`); + } + + await client.query("COMMIT"); + + logger.info("=== 다중 테이블 저장 완료 ===", { + mainTable: mainTableName, + mainPk: savedPkValue, + subTableResultsCount: subTableResults.length, + }); + + res.json({ + success: true, + message: "다중 테이블 저장이 완료되었습니다.", + data: { + main: savedMainData, + subTables: subTableResults, + }, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + + logger.error("다중 테이블 저장 실패:", { + message: error.message, + stack: error.stack, + }); + + res.status(500).json({ + success: false, + message: error.message || "다중 테이블 저장에 실패했습니다.", + error: error.message, + }); + } finally { + client.release(); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5ea98489..d0716d59 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -24,6 +24,7 @@ import { getLogData, toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 + multiTableSave, // 🆕 범용 다중 테이블 저장 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -198,4 +199,17 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu); +// ======================================== +// 범용 다중 테이블 저장 API +// ======================================== + +/** + * 다중 테이블 저장 (메인 + 서브 테이블) + * POST /api/table-management/multi-table-save + * + * 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다. + * 사원+부서, 주문+주문상세 등 1:N 관계 데이터 저장에 사용됩니다. + */ +router.post("/multi-table-save", multiTableSave); + export default router; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8609623b..816483fc 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -448,6 +448,9 @@ export const DynamicComponentRenderer: React.FC = isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) groupedData: props.groupedData, + // 🆕 UniversalFormModal용 initialData 전달 + // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) + initialData: originalData || formData, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index c875316a..37e16c6d 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -729,7 +729,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC -
- 컬럼 {index + 1} - -
+
+
+ 컬럼 {index + 1} + +
{/* 테이블 선택 */}
@@ -1156,7 +1156,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC컬럼 updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨명 (미입력 시 컬럼명 사용)" - className="h-8 text-xs" - /> -
+ updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
{/* 표시 위치 */} -
+
- -
+
+ ); })} {(config.rightPanel?.displayColumns || []).length === 0 && ( @@ -1273,14 +1273,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC t.table_name === selectedTableName)?.table_comment || selectedTableName; return ( -
+
- -
+ +
); })} {(config.rightPanel?.displayColumns || []).length === 0 && ( diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 3938645d..4eab9f72 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -115,10 +115,37 @@ export function UniversalFormModalComponent({ itemId: string; }>({ open: false, sectionId: "", itemId: "" }); - // 초기화 + // 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시) + const capturedInitialData = useRef | undefined>(undefined); + const hasInitialized = useRef(false); + + // 초기화 - 최초 마운트 시에만 실행 useEffect(() => { + // 이미 초기화되었으면 스킵 + if (hasInitialized.current) { + console.log("[UniversalFormModal] 이미 초기화됨, 스킵"); + return; + } + + // 최초 initialData 캡처 (이후 변경되어도 이 값 사용) + if (initialData && Object.keys(initialData).length > 0) { + capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사 + console.log("[UniversalFormModal] initialData 캡처:", capturedInitialData.current); + } + + hasInitialized.current = true; initializeForm(); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행 + + // config 변경 시에만 재초기화 (initialData 변경은 무시) + useEffect(() => { + if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵 + + console.log("[UniversalFormModal] config 변경 감지, 재초기화"); + initializeForm(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // 필드 레벨 linkedFieldGroup 데이터 로드 useEffect(() => { @@ -149,6 +176,10 @@ export function UniversalFormModalComponent({ // 폼 초기화 const initializeForm = useCallback(async () => { + // 캡처된 initialData 사용 (props로 전달된 initialData가 아닌) + const effectiveInitialData = capturedInitialData.current || initialData; + console.log("[UniversalFormModal] 폼 초기화 시작, effectiveInitialData:", effectiveInitialData); + const newFormData: FormDataState = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newCollapsed = new Set(); @@ -174,11 +205,15 @@ export function UniversalFormModalComponent({ // 기본값 설정 let value = field.defaultValue ?? ""; - // 부모에서 전달받은 값 적용 - if (field.receiveFromParent && initialData) { + // 부모에서 전달받은 값 적용 (receiveFromParent 또는 effectiveInitialData에 해당 값이 있으면) + if (effectiveInitialData) { const parentField = field.parentFieldName || field.columnName; - if (initialData[parentField] !== undefined) { - value = initialData[parentField]; + if (effectiveInitialData[parentField] !== undefined) { + // receiveFromParent가 true이거나, effectiveInitialData에 값이 있으면 적용 + if (field.receiveFromParent || value === "" || value === undefined) { + value = effectiveInitialData[parentField]; + console.log(`[UniversalFormModal] 필드 ${field.columnName}: initialData에서 값 적용 = ${value}`); + } } } @@ -190,11 +225,12 @@ export function UniversalFormModalComponent({ setFormData(newFormData); setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); - setOriginalData(initialData || {}); + setOriginalData(effectiveInitialData || {}); // 채번규칙 자동 생성 await generateNumberingValues(newFormData); - }, [config, initialData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용) // 반복 섹션 아이템 생성 const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => { @@ -344,15 +380,30 @@ export function UniversalFormModalComponent({ if (optionConfig.type === "static") { options = optionConfig.staticOptions || []; } else if (optionConfig.type === "table" && optionConfig.tableName) { - const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, { - params: { limit: 1000 }, + // POST 방식으로 테이블 데이터 조회 (autoFilter 포함) + const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, { + page: 1, + size: 1000, + autoFilter: { enabled: true, filterColumn: "company_code" }, }); - if (response.data?.success && response.data?.data) { - options = response.data.data.map((row: any) => ({ - value: String(row[optionConfig.valueColumn || "id"]), - label: String(row[optionConfig.labelColumn || "name"]), - })); + + // 응답 데이터 파싱 + let dataArray: any[] = []; + if (response.data?.success) { + const responseData = response.data?.data; + if (responseData?.data && Array.isArray(responseData.data)) { + dataArray = responseData.data; + } else if (Array.isArray(responseData)) { + dataArray = responseData; + } else if (responseData?.rows && Array.isArray(responseData.rows)) { + dataArray = responseData.rows; + } } + + options = dataArray.map((row: any) => ({ + value: String(row[optionConfig.valueColumn || "id"]), + label: String(row[optionConfig.labelColumn || "name"]), + })); } else if (optionConfig.type === "code" && optionConfig.codeCategory) { const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); if (response.data?.success && response.data?.data) { @@ -444,7 +495,7 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 단일 행 저장 + // 단일 행 저장 const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; @@ -532,9 +583,9 @@ export function UniversalFormModalComponent({ // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등) const mainSectionData: any = {}; mainSectionFields.forEach((fieldName) => { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; + } }); console.log("[UniversalFormModal] 공통 데이터:", commonData); @@ -612,84 +663,113 @@ export function UniversalFormModalComponent({ console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); }, [config.sections, config.saveConfig, formData, repeatSections]); - // 커스텀 API 저장 (사원+부서 통합 저장 등) + // 다중 테이블 저장 (범용) + const saveWithMultiTable = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave?.multiTable) return; + + const { multiTable } = customApiSave; + console.log("[UniversalFormModal] 다중 테이블 저장 시작:", multiTable); + console.log("[UniversalFormModal] 현재 formData:", formData); + console.log("[UniversalFormModal] 현재 repeatSections:", repeatSections); + + // 1. 메인 테이블 데이터 구성 + const mainData: Record = {}; + config.sections.forEach((section) => { + if (section.repeatable) return; // 반복 섹션은 제외 + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + mainData[field.columnName] = value; + } + }); + }); + + // 2. 서브 테이블 데이터 구성 + const subTablesData: Array<{ + tableName: string; + linkColumn: { mainField: string; subColumn: string }; + items: Record[]; + options?: { + saveMainAsFirst?: boolean; + mainFieldMappings?: Array<{ formField: string; targetColumn: string }>; + mainMarkerColumn?: string; + mainMarkerValue?: any; + subMarkerValue?: any; + deleteExistingBefore?: boolean; + }; + }> = []; + + for (const subTableConfig of multiTable.subTables || []) { + if (!subTableConfig.enabled || !subTableConfig.tableName || !subTableConfig.repeatSectionId) { + continue; + } + + const subItems: Record[] = []; + const repeatData = repeatSections[subTableConfig.repeatSectionId] || []; + + // 반복 섹션 데이터를 필드 매핑에 따라 변환 + for (const item of repeatData) { + const mappedItem: Record = {}; + + // 연결 컬럼 값 설정 + if (subTableConfig.linkColumn?.mainField && subTableConfig.linkColumn?.subColumn) { + mappedItem[subTableConfig.linkColumn.subColumn] = mainData[subTableConfig.linkColumn.mainField]; + } + + // 필드 매핑에 따라 데이터 변환 + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.formField && mapping.targetColumn) { + mappedItem[mapping.targetColumn] = item[mapping.formField]; + } + } + + // 메인/서브 구분 컬럼 설정 (서브 데이터는 서브 마커 값) + if (subTableConfig.options?.mainMarkerColumn) { + mappedItem[subTableConfig.options.mainMarkerColumn] = subTableConfig.options?.subMarkerValue ?? false; + } + + if (Object.keys(mappedItem).length > 0) { + subItems.push(mappedItem); + } + } + + subTablesData.push({ + tableName: subTableConfig.tableName, + linkColumn: subTableConfig.linkColumn, + items: subItems, + options: subTableConfig.options, + }); + } + + // 3. 범용 다중 테이블 저장 API 호출 + console.log("[UniversalFormModal] 다중 테이블 저장 데이터:", { + mainTable: multiTable.mainTable, + mainData, + subTablesData, + }); + + const response = await apiClient.post("/table-management/multi-table-save", { + mainTable: multiTable.mainTable, + mainData, + subTables: subTablesData, + isUpdate: !!initialData?.[multiTable.mainTable.primaryKeyColumn], + }); + + if (!response.data?.success) { + throw new Error(response.data?.message || "다중 테이블 저장 실패"); + } + + console.log("[UniversalFormModal] 다중 테이블 저장 완료:", response.data); + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // 커스텀 API 저장 const saveWithCustomApi = useCallback(async () => { const { customApiSave } = config.saveConfig; if (!customApiSave) return; console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); - const saveUserWithDeptApi = async () => { - const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; - - // 1. userInfo 데이터 구성 - const userInfo: Record = {}; - - // 모든 필드에서 user_info에 해당하는 데이터 추출 - config.sections.forEach((section) => { - if (section.repeatable) return; // 반복 섹션은 제외 - - section.fields.forEach((field) => { - const value = formData[field.columnName]; - if (value !== undefined && value !== null && value !== "") { - userInfo[field.columnName] = value; - } - }); - }); - - // 2. mainDept 데이터 구성 - let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; - - if (mainDeptFields) { - const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; - if (deptCode) { - mainDept = { - dept_code: deptCode, - dept_name: formData[mainDeptFields.deptNameField || "dept_name"], - position_name: formData[mainDeptFields.positionNameField || "position_name"], - }; - } - } - - // 3. subDepts 데이터 구성 (반복 섹션에서) - const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; - - if (subDeptSectionId && repeatSections[subDeptSectionId]) { - const subDeptItems = repeatSections[subDeptSectionId]; - const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; - const deptNameField = subDeptFields?.deptNameField || "dept_name"; - const positionNameField = subDeptFields?.positionNameField || "position_name"; - - subDeptItems.forEach((item) => { - const deptCode = item[deptCodeField]; - if (deptCode) { - subDepts.push({ - dept_code: deptCode, - dept_name: item[deptNameField], - position_name: item[positionNameField], - }); - } - }); - } - - // 4. API 호출 - console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); - - const { saveUserWithDept } = await import("@/lib/api/user"); - const response = await saveUserWithDept({ - userInfo: userInfo as any, - mainDept, - subDepts, - isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드 - }); - - if (!response.success) { - throw new Error(response.message || "사원 저장 실패"); - } - - console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); - }; - const saveWithGenericCustomApi = async () => { if (!customApiSave.customEndpoint) { throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다."); @@ -720,8 +800,8 @@ export function UniversalFormModalComponent({ }; switch (customApiSave.apiType) { - case "user-with-dept": - await saveUserWithDeptApi(); + case "multi-table": + await saveWithMultiTable(); break; case "custom": await saveWithGenericCustomApi(); @@ -729,10 +809,16 @@ export function UniversalFormModalComponent({ default: throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); } - }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + }, [config.saveConfig, formData, repeatSections, saveWithMultiTable]); // 저장 처리 const handleSave = useCallback(async () => { + console.log("[UniversalFormModal] 저장 시작, saveConfig:", { + tableName: config.saveConfig.tableName, + customApiSave: config.saveConfig.customApiSave, + multiRowSave: config.saveConfig.multiRowSave, + }); + // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크 if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) { toast.error("저장할 테이블이 설정되지 않았습니다."); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 8552cd6f..eb52a10f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -108,6 +108,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.sections]); + // 다중 테이블 저장 설정의 메인/서브 테이블 컬럼 로드 + useEffect(() => { + const customApiSave = config.saveConfig.customApiSave; + if (customApiSave?.enabled && customApiSave?.multiTable) { + // 메인 테이블 컬럼 로드 + const mainTableName = customApiSave.multiTable.mainTable?.tableName; + if (mainTableName && !tableColumns[mainTableName]) { + loadTableColumns(mainTableName); + } + // 서브 테이블들 컬럼 로드 + customApiSave.multiTable.subTables?.forEach((subTable) => { + if (subTable.tableName && !tableColumns[subTable.tableName]) { + loadTableColumns(subTable.tableName); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.saveConfig.customApiSave]); + const loadTables = async () => { try { const response = await apiClient.get("/table-management/tables"); @@ -425,58 +444,58 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor ) : ( <> - - - - - - - - - 테이블을 찾을 수 없습니다 - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- 컬럼 {currentColumns.length}개 로드됨 -

+ + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ 컬럼 {currentColumns.length}개 로드됨 +

)} )} @@ -592,29 +611,41 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor )} - {/* 커스텀 API 저장 설정 */} -
-
- 전용 API 저장 + {/* 다중 테이블 저장 설정 (범용) */} +
+
+ 다중 테이블 저장 updateSaveConfig({ - customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + customApiSave: { + ...config.saveConfig.customApiSave, + enabled: checked, + apiType: "multi-table", + multiTable: checked ? { + enabled: true, + mainTable: { tableName: config.saveConfig.tableName || "", primaryKeyColumn: "" }, + subTables: [], + } : undefined, + }, }) } />
- 테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다. + + 메인 테이블 + 서브 테이블(반복 섹션)에 트랜잭션으로 저장합니다. +
예: 사원+부서, 주문+주문상세, 프로젝트+멤버 등 +
{config.saveConfig.customApiSave?.enabled && ( -
+
{/* API 타입 선택 */}
- +
- {/* 사원+부서 통합 저장 설정 */} - {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( -
-

- user_info와 user_dept 테이블에 트랜잭션으로 저장합니다. - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다. -

- - {/* 메인 부서 필드 매핑 */} -
- -
-
- 부서코드: - -
-
- 부서명: - -
-
- 직급: - -
+ {/* 다중 테이블 저장 설정 */} + {config.saveConfig.customApiSave?.apiType === "multi-table" && ( +
+ {/* 메인 테이블 설정 */} +
+ + 비반복 섹션의 데이터가 저장될 메인 테이블입니다. + +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((table) => ( + { + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: table.name, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }); + // 테이블 컬럼 로드 + if (!tableColumns[table.name]) { + loadTableColumns(table.name); + } + }} + className="text-[10px]" + > + +
+ {table.label || table.name} + {table.label && {table.name}} +
+
+ ))} +
+
+
+
+
-
- - {/* 겸직 부서 반복 섹션 */} -
- - + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: { + ...config.saveConfig.customApiSave?.multiTable?.mainTable, + tableName: config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || "", + primaryKeyColumn: value === "_none_" ? "" : value, + }, + subTables: config.saveConfig.customApiSave?.multiTable?.subTables || [], + }, + }, + }) + } + > + + + + + 선택 안함 + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + + 서브 테이블과 연결할 때 사용할 PK 컬럼 +
- {/* 겸직 부서 필드 매핑 */} - {config.saveConfig.customApiSave?.subDeptSectionId && ( -
- -
-
- 부서코드: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { ...newSubTables[subIndex], repeatSectionId: value === "_none_" ? "" : value }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + 선택 안함 {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + .filter((s) => s.repeatable) + .map((section) => ( + + 반복 섹션: {section.title} ))}
-
- 부서명: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + linkColumn: { ...newSubTables[subIndex].linkColumn, mainField: value === "_none_" ? "" : value }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} + }); + }} + > + + + + + 선택 + {/* 메인 테이블의 컬럼 목록에서 선택 */} + {(tableColumns[config.saveConfig.customApiSave?.multiTable?.mainTable?.tableName || ""] || []).map((col) => ( + + {col.label || col.name} ))} - - + + +
+ {/* 서브 테이블 컬럼 선택 (FK 컬럼) */} + +
+ 메인 테이블과 서브 테이블을 연결할 컬럼
-
- 직급: - { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + const newMappings = [...(newSubTables[subIndex].fieldMappings || [])]; + newMappings[mapIndex] = { ...newMappings[mapIndex], formField: value === "_none_" ? "" : value }; + newSubTables[subIndex] = { ...newSubTables[subIndex], fieldMappings: newMappings }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + > + + + + + 선택 + {sectionFields + .filter((f) => f.columnName && f.columnName.trim() !== "") + .map((field) => ( + + {field.label} + + ))} + + +
+ +
+ ); + })} +
+ )} + + {/* 추가 옵션 */} +
+ +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, saveMainAsFirst: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, }, - }, - }) - } - > - - - - - {config.sections - .find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId) - ?.fields.map((field) => ( - - {field.label} - - ))} - - + }); + }} + className="shrink-0" + /> + +
+ + {subTable.options?.saveMainAsFirst && ( +
+ + + 메인/서브 구분용 컬럼 (예: is_primary) +
+ )} + +
+ { + const newSubTables = [...(config.saveConfig.customApiSave?.multiTable?.subTables || [])]; + newSubTables[subIndex] = { + ...newSubTables[subIndex], + options: { ...newSubTables[subIndex].options, deleteExistingBefore: !!checked }, + }; + updateSaveConfig({ + customApiSave: { + ...config.saveConfig.customApiSave, + multiTable: { + ...config.saveConfig.customApiSave?.multiTable, + enabled: true, + mainTable: config.saveConfig.customApiSave?.multiTable?.mainTable || { tableName: "", primaryKeyColumn: "" }, + subTables: newSubTables, + }, + }, + }); + }} + className="shrink-0" + /> + +
-
- )} + ))} + + {(config.saveConfig.customApiSave?.multiTable?.subTables || []).length === 0 && ( +

+ 서브 테이블을 추가하세요 +

+ )} +
)} {/* 커스텀 API 설정 */} {config.saveConfig.customApiSave?.apiType === "custom" && (
-
+
- - updateSaveConfig({ + onChange={(e) => + updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, - }) - } + }) + } placeholder="/api/custom/endpoint" - className="h-6 text-[10px] mt-1" - /> -
-
+ className="h-6 text-[10px] mt-1" + /> +
+
-
+
)}
@@ -1571,9 +1983,9 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 다중 컬럼 저장 (select 타입만) */} {selectedField.fieldType === "select" && ( -
-
- 다중 컬럼 저장 +
+
+ 다중 컬럼 저장 @@ -1592,10 +2004,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {selectedField.linkedFieldGroup?.enabled && ( -
+
{/* 소스 테이블 */} -
- +
+ { + const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) => + i === mappingIndex ? { ...m, sourceColumn: value } : m + ); + updateField(selectedSection.id, selectedField.id, { + linkedFieldGroup: { + ...selectedField.linkedFieldGroup, + mappings: updatedMappings, + }, + }); + }} + > + + + + + {(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => ( + + {col.label || col.name} + + ))} + + +
+
+ {/* 저장할 테이블 선택 */} +
+ + +
+ {/* 저장할 컬럼 선택 */} +
+ + +
-
- - -
-
- - -
-
- ))} + ); + })} {(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (

diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index 04f7df0e..75dcf4fd 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -108,6 +108,7 @@ export interface FormFieldConfig { // 연동 필드 매핑 설정 export interface LinkedFieldMapping { sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code") + targetTable?: string; // 저장할 테이블 (선택, 없으면 자동 결정) targetColumn: string; // 저장할 컬럼 (예: "position_code") } @@ -194,42 +195,92 @@ export interface SaveConfig { }; } +/** + * 서브 테이블 필드 매핑 + * 폼 필드(columnName)를 서브 테이블의 컬럼에 매핑합니다. + */ +export interface SubTableFieldMapping { + formField: string; // 폼 필드의 columnName + targetColumn: string; // 서브 테이블의 컬럼명 +} + +/** + * 서브 테이블 저장 설정 + * 반복 섹션의 데이터를 별도 테이블에 저장하는 설정입니다. + */ +export interface SubTableSaveConfig { + enabled: boolean; + tableName: string; // 서브 테이블명 (예: user_dept, order_items) + repeatSectionId: string; // 연결할 반복 섹션 ID + + // 연결 설정 (메인 테이블과 서브 테이블 연결) + linkColumn: { + mainField: string; // 메인 테이블의 연결 필드 (예: user_id) + subColumn: string; // 서브 테이블의 연결 컬럼 (예: user_id) + }; + + // 필드 매핑 (반복 섹션 필드 → 서브 테이블 컬럼) + fieldMappings: SubTableFieldMapping[]; + + // 추가 옵션 + options?: { + // 메인 데이터도 서브 테이블에 저장 (1:N에서 메인도 저장할 때) + saveMainAsFirst?: boolean; + mainFieldMappings?: SubTableFieldMapping[]; // 메인 데이터용 필드 매핑 + mainMarkerColumn?: string; // 메인 여부 표시 컬럼 (예: is_primary) + mainMarkerValue?: any; // 메인일 때 값 (예: true) + subMarkerValue?: any; // 서브일 때 값 (예: false) + + // 저장 전 기존 데이터 삭제 + deleteExistingBefore?: boolean; + deleteOnlySubItems?: boolean; // 메인 항목은 유지하고 서브만 삭제 + }; +} + +/** + * 다중 테이블 저장 설정 (범용) + * + * 메인 테이블 + 서브 테이블(들)에 트랜잭션으로 저장합니다. + * + * ## 사용 예시 + * + * ### 사원 + 부서 (user_info + user_dept) + * - 메인 테이블: user_info (사원 정보) + * - 서브 테이블: user_dept (부서 관계, 메인 부서 + 겸직 부서) + * + * ### 주문 + 주문상세 (orders + order_items) + * - 메인 테이블: orders (주문 정보) + * - 서브 테이블: order_items (주문 상품 목록) + */ +export interface MultiTableSaveConfig { + enabled: boolean; + + // 메인 테이블 설정 + mainTable: { + tableName: string; // 메인 테이블명 + primaryKeyColumn: string; // PK 컬럼명 + }; + + // 서브 테이블 설정 (여러 개 가능) + subTables: SubTableSaveConfig[]; +} + /** * 커스텀 API 저장 설정 * * 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다. * 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다. - * - * ## 지원하는 API 타입 - * - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept) - * - * ## 데이터 매핑 설정 - * - `userInfoFields`: user_info 테이블에 저장할 필드 매핑 - * - `mainDeptFields`: 메인 부서 정보 필드 매핑 - * - `subDeptSectionId`: 겸직 부서 반복 섹션 ID */ export interface CustomApiSaveConfig { enabled: boolean; - apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 + apiType: "multi-table" | "custom"; // API 타입 - // user-with-dept 전용 설정 - userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) - mainDeptFields?: { - deptCodeField?: string; // 메인 부서코드 필드명 - deptNameField?: string; // 메인 부서명 필드명 - positionNameField?: string; // 메인 직급 필드명 - }; - subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID - subDeptFields?: { - deptCodeField?: string; // 겸직 부서코드 필드명 - deptNameField?: string; // 겸직 부서명 필드명 - positionNameField?: string; // 겸직 직급 필드명 - }; + // 다중 테이블 저장 설정 (범용) + multiTable?: MultiTableSaveConfig; // 커스텀 API 전용 설정 customEndpoint?: string; // 커스텀 API 엔드포인트 customMethod?: "POST" | "PUT"; // HTTP 메서드 - customDataTransform?: string; // 데이터 변환 함수명 (추후 확장) } // 모달 설정 From b15b6e21ea1017d72283b1cec341770d41cfd31a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 18:23:28 +0900 Subject: [PATCH 05/25] =?UTF-8?q?fix(UniversalFormModal):=20=EB=B0=98?= =?UTF-8?q?=EB=B3=B5=20=EC=84=B9=EC=85=98=20linkedFieldGroup=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EB=B0=8F=20=EC=84=9C=EB=B8=8C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderFieldWithColumns()에 repeatContext 파라미터 추가 - linkedFieldGroup 선택 시 repeatContext 유무에 따라 formData/repeatSections 분기 저장 - multiTableSave: UPSERT 대신 SELECT-UPDATE/INSERT 명시적 분기로 변경 - ON CONFLICT 조건 불일치 에러 방지 - 서브 테이블 저장 상세 로그 추가 --- .../controllers/tableManagementController.ts | 94 ++++++++++++++----- .../UniversalFormModalComponent.tsx | 75 +++++++++++++-- 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index ce6a73b9..e4a67d3b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2010,37 +2010,83 @@ export async function multiTableSave( mainSubItem.company_code = companyCode; } - const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); - const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); - const mainSubValues = Object.values(mainSubItem); + logger.info(`서브 테이블 ${tableName} 메인 데이터 저장 준비:`, JSON.stringify(mainSubItem)); - // UPSERT 쿼리 (PK가 있다면) - const mainSubInsertQuery = ` - INSERT INTO "${tableName}" (${mainSubColumns}) - VALUES (${mainSubPlaceholders}) - ON CONFLICT ("${linkColumn.subColumn}"${options.mainMarkerColumn ? `, "${options.mainMarkerColumn}"` : ""}) - DO UPDATE SET - ${Object.keys(mainSubItem) - .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn) - .map(col => `"${col}" = EXCLUDED."${col}"`) - .join(", ") || "updated_at = NOW()"} - RETURNING * + // 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합) + const checkQuery = ` + SELECT * FROM "${tableName}" + WHERE "${linkColumn.subColumn}" = $1 + ${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""} + ${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""} + LIMIT 1 `; + const checkParams: any[] = [savedPkValue]; + if (options.mainMarkerColumn) { + checkParams.push(options.mainMarkerValue ?? true); + } + if (companyCode !== "*") { + checkParams.push(companyCode); + } - try { - logger.info(`서브 테이블 ${tableName} 메인 데이터 저장:`, { mainSubInsertQuery, mainSubValues }); - const mainSubResult = await client.query(mainSubInsertQuery, mainSubValues); - subTableResults.push({ tableName, type: "main", data: mainSubResult.rows[0] }); - } catch (err: any) { - // ON CONFLICT 실패 시 일반 INSERT 시도 - logger.warn(`서브 테이블 ${tableName} UPSERT 실패, 일반 INSERT 시도:`, err.message); - const simpleInsertQuery = ` + logger.info(`서브 테이블 ${tableName} 기존 데이터 확인 - 쿼리: ${checkQuery}`); + logger.info(`서브 테이블 ${tableName} 기존 데이터 확인 - 파라미터: ${JSON.stringify(checkParams)}`); + + const existingResult = await client.query(checkQuery, checkParams); + + if (existingResult.rows.length > 0) { + // UPDATE + const updateColumns = Object.keys(mainSubItem) + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .map((col, idx) => `"${col}" = $${idx + 1}`) + .join(", "); + + const updateValues = Object.keys(mainSubItem) + .filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code") + .map(col => mainSubItem[col]); + + if (updateColumns) { + const updateQuery = ` + UPDATE "${tableName}" + SET ${updateColumns} + WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1} + ${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""} + ${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""} + RETURNING * + `; + const updateParams = [...updateValues, savedPkValue]; + if (options.mainMarkerColumn) { + updateParams.push(options.mainMarkerValue ?? true); + } + if (companyCode !== "*") { + updateParams.push(companyCode); + } + + logger.info(`서브 테이블 ${tableName} 메인 데이터 UPDATE - 쿼리: ${updateQuery}`); + logger.info(`서브 테이블 ${tableName} 메인 데이터 UPDATE - 값: ${JSON.stringify(updateParams)}`); + + const updateResult = await client.query(updateQuery, updateParams); + subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); + } else { + logger.info(`서브 테이블 ${tableName} 메인 데이터 - 업데이트할 컬럼 없음, 기존 데이터 유지`); + subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); + } + } else { + // INSERT + const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", "); + const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", "); + const mainSubValues = Object.values(mainSubItem); + + const insertQuery = ` INSERT INTO "${tableName}" (${mainSubColumns}) VALUES (${mainSubPlaceholders}) RETURNING * `; - const simpleResult = await client.query(simpleInsertQuery, mainSubValues); - subTableResults.push({ tableName, type: "main", data: simpleResult.rows[0] }); + + logger.info(`서브 테이블 ${tableName} 메인 데이터 INSERT - 쿼리: ${insertQuery}`); + logger.info(`서브 테이블 ${tableName} 메인 데이터 INSERT - 값: ${JSON.stringify(mainSubValues)}`); + + const insertResult = await client.query(insertQuery, mainSubValues); + subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); } } diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 4eab9f72..eda2f94d 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -734,11 +734,54 @@ export function UniversalFormModalComponent({ } } + // saveMainAsFirst가 활성화된 경우, 메인 데이터를 서브 테이블에 저장하기 위한 매핑 생성 + let mainFieldMappings: Array<{ formField: string; targetColumn: string }> | undefined; + if (subTableConfig.options?.saveMainAsFirst) { + mainFieldMappings = []; + + // 메인 섹션(비반복)의 필드들을 서브 테이블에 매핑 + // 서브 테이블의 fieldMappings에서 targetColumn을 찾아서 매핑 + for (const mapping of subTableConfig.fieldMappings || []) { + if (mapping.targetColumn) { + // 메인 데이터에서 동일한 컬럼명이 있으면 매핑 + if (mainData[mapping.targetColumn] !== undefined) { + mainFieldMappings.push({ + formField: mapping.targetColumn, + targetColumn: mapping.targetColumn, + }); + } + // 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑 + else { + config.sections.forEach((section) => { + if (section.repeatable) return; + const matchingField = section.fields.find(f => f.columnName === mapping.targetColumn); + if (matchingField && mainData[matchingField.columnName] !== undefined) { + mainFieldMappings!.push({ + formField: matchingField.columnName, + targetColumn: mapping.targetColumn, + }); + } + }); + } + } + } + + // 중복 제거 + mainFieldMappings = mainFieldMappings.filter((m, idx, arr) => + arr.findIndex(x => x.targetColumn === m.targetColumn) === idx + ); + + console.log("[UniversalFormModal] 메인 필드 매핑 생성:", mainFieldMappings); + } + subTablesData.push({ tableName: subTableConfig.tableName, linkColumn: subTableConfig.linkColumn, items: subItems, - options: subTableConfig.options, + options: { + ...subTableConfig.options, + mainFieldMappings, // 메인 데이터 매핑 추가 + }, }); } @@ -885,12 +928,14 @@ export function UniversalFormModalComponent({ }, [initializeForm]); // 필드 요소 렌더링 (입력 컴포넌트만) + // repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달 const renderFieldElement = ( field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string, isDisabled: boolean, + repeatContext?: { sectionId: string; itemId: string }, ) => { return (() => { switch (field.fieldType) { @@ -969,11 +1014,24 @@ export function UniversalFormModalComponent({ lfg.mappings.forEach((mapping) => { if (mapping.sourceColumn && mapping.targetColumn) { const mappedValue = selectedRow[mapping.sourceColumn]; - // formData에 직접 저장 - setFormData((prev) => ({ - ...prev, - [mapping.targetColumn]: mappedValue, - })); + + // 반복 섹션인 경우 repeatSections에 저장, 아니면 formData에 저장 + if (repeatContext) { + setRepeatSections((prev) => { + const items = prev[repeatContext.sectionId] || []; + const newItems = items.map((item) => + item._id === repeatContext.itemId + ? { ...item, [mapping.targetColumn]: mappedValue } + : item + ); + return { ...prev, [repeatContext.sectionId]: newItems }; + }); + } else { + setFormData((prev) => ({ + ...prev, + [mapping.targetColumn]: mappedValue, + })); + } } }); } @@ -1116,12 +1174,14 @@ export function UniversalFormModalComponent({ }; // 필드 렌더링 (섹션 열 수 적용) + // repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달 const renderFieldWithColumns = ( field: FormFieldConfig, value: any, onChangeHandler: (value: any) => void, fieldKey: string, sectionColumns: number = 2, + repeatContext?: { sectionId: string; itemId: string }, ) => { // 섹션 열 수에 따른 기본 gridSpan 계산 (섹션 열 수가 우선) const defaultSpan = getDefaultGridSpan(sectionColumns); @@ -1135,7 +1195,7 @@ export function UniversalFormModalComponent({ return null; } - const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled); + const fieldElement = renderFieldElement(field, value, onChangeHandler, fieldKey, isDisabled, repeatContext); if (field.fieldType === "checkbox") { return ( @@ -1275,6 +1335,7 @@ export function UniversalFormModalComponent({ (value) => handleRepeatFieldChange(section.id, item._id, field.columnName, value), `${section.id}-${item._id}-${field.id}`, sectionColumns, + { sectionId: section.id, itemId: item._id }, // 반복 섹션 컨텍스트 전달 ), )}

From d908de7f669bd3f54c69947daa37466866c87e5a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 8 Dec 2025 19:10:07 +0900 Subject: [PATCH 06/25] =?UTF-8?q?fix(numbering-rule):=20=EC=B1=84=EB=B2=88?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20allocateN?= =?UTF-8?q?umberingCode=EB=A1=9C=20=EC=8B=A4=EC=A0=9C=20=EC=88=9C=EB=B2=88?= =?UTF-8?q?=20=ED=95=A0=EB=8B=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generateNumberingCode를 allocateNumberingCode로 변경 (순번 실제 증가) - saveSingleRow/saveMultipleRows/saveWithMultiTable 모두 적용 - NumberingRuleCard: 파트 타입 변경 시 defaultAutoConfig 적용 - NumberingRuleDesigner: 저장 시 partsWithDefaults로 기본값 병합 - sequenceLength/numberLength 기본값 4에서 3으로 변경 - 불필요한 console.log 제거 --- .../controllers/tableManagementController.ts | 12 -- .../src/services/numberingRuleService.ts | 8 +- .../numbering-rule/NumberingRuleCard.tsx | 15 +- .../numbering-rule/NumberingRuleDesigner.tsx | 21 +++ .../UniversalFormModalComponent.tsx | 129 ++++++++---------- .../UniversalFormModalConfigPanel.tsx | 6 +- 6 files changed, 101 insertions(+), 90 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index e4a67d3b..2dfe0770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2010,8 +2010,6 @@ export async function multiTableSave( mainSubItem.company_code = companyCode; } - logger.info(`서브 테이블 ${tableName} 메인 데이터 저장 준비:`, JSON.stringify(mainSubItem)); - // 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합) const checkQuery = ` SELECT * FROM "${tableName}" @@ -2027,9 +2025,6 @@ export async function multiTableSave( if (companyCode !== "*") { checkParams.push(companyCode); } - - logger.info(`서브 테이블 ${tableName} 기존 데이터 확인 - 쿼리: ${checkQuery}`); - logger.info(`서브 테이블 ${tableName} 기존 데이터 확인 - 파라미터: ${JSON.stringify(checkParams)}`); const existingResult = await client.query(checkQuery, checkParams); @@ -2061,13 +2056,9 @@ export async function multiTableSave( updateParams.push(companyCode); } - logger.info(`서브 테이블 ${tableName} 메인 데이터 UPDATE - 쿼리: ${updateQuery}`); - logger.info(`서브 테이블 ${tableName} 메인 데이터 UPDATE - 값: ${JSON.stringify(updateParams)}`); - const updateResult = await client.query(updateQuery, updateParams); subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] }); } else { - logger.info(`서브 테이블 ${tableName} 메인 데이터 - 업데이트할 컬럼 없음, 기존 데이터 유지`); subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] }); } } else { @@ -2082,9 +2073,6 @@ export async function multiTableSave( RETURNING * `; - logger.info(`서브 테이블 ${tableName} 메인 데이터 INSERT - 쿼리: ${insertQuery}`); - logger.info(`서브 테이블 ${tableName} 메인 데이터 INSERT - 값: ${JSON.stringify(mainSubValues)}`); - const insertResult = await client.query(insertQuery, mainSubValues); subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] }); } diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 83b4f63b..5272547a 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -897,13 +897,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // 순번 (현재 순번으로 미리보기, 증가 안 함) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } @@ -957,13 +957,13 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { // 순번 (자동 증가 숫자) - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; return String(rule.currentSequence || 1).padStart(length, "0"); } case "number": { // 숫자 (고정 자릿수) - const length = autoConfig.numberLength || 4; + const length = autoConfig.numberLength || 3; const value = autoConfig.numberValue || 1; return String(value).padStart(length, "0"); } diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index 83fcd3a2..8d362f5d 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -48,7 +48,20 @@ export const NumberingRuleCard: React.FC = ({ - updateSaveConfig({ + updateSaveConfig({ customApiSave: { ...config.saveConfig.customApiSave, multiTable: { @@ -893,7 +893,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
{table.label || table.name} {table.label && {table.name}} -
+
))} From fa59235cd2a75190b036b5ffc461e6c847c839ad Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 9 Dec 2025 09:22:10 +0900 Subject: [PATCH 07/25] =?UTF-8?q?fix(split-panel-layout2):=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=ED=95=AD=EB=AA=A9=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=83=81=ED=83=9C=20=EB=B9=84=EA=B5=90=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - idColumn 자동 감지 로직 추가 (id > dept_code > code 순 폴백) - isSelected 비교 시 객체 동일성 및 undefined 체크 추가 - hierarchyConfig.idColumn 미설정 시에도 정상 동작 --- .../SplitPanelLayout2Component.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 3bdd2015..6415519e 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -812,11 +812,22 @@ export const SplitPanelLayout2Component: React.FC { - const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; + // ID 컬럼 결정: 설정값 > 데이터에 존재하는 일반적인 ID 컬럼 > 폴백 + const configIdColumn = config.leftPanel?.hierarchyConfig?.idColumn; + const idColumn = configIdColumn || + (item["id"] !== undefined ? "id" : + item["dept_code"] !== undefined ? "dept_code" : + item["code"] !== undefined ? "code" : "id"); const itemId = item[idColumn] ?? `item-${level}-${index}`; const hasChildren = item.children?.length > 0; const isExpanded = expandedItems.has(String(itemId)); - const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; + // 선택 상태 확인: 동일한 객체이거나 idColumn 값이 일치해야 함 + const isSelected = selectedLeftItem && ( + selectedLeftItem === item || + (item[idColumn] !== undefined && + selectedLeftItem[idColumn] !== undefined && + selectedLeftItem[idColumn] === item[idColumn]) + ); // displayRow 설정에 따라 컬럼 분류 const displayColumns = config.leftPanel?.displayColumns || []; From 469c8b2e57905d8ddbbd1e4186f1e939565ed082 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 9 Dec 2025 10:18:07 +0900 Subject: [PATCH 08/25] =?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 0aaab453297116ee1e2ec25d6d9e6eb0ae3690fb Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 9 Dec 2025 11:15:18 +0900 Subject: [PATCH 09/25] =?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 10/25] =?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 167c3cd26b27b07cd452e54081db716172100b76 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 9 Dec 2025 12:14:32 +0900 Subject: [PATCH 11/25] =?UTF-8?q?=EC=9E=91=EB=8F=99=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/page.tsx | 374 ++++++++++++++--------------- 1 file changed, 185 insertions(+), 189 deletions(-) diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx index c93c117a..f8d5d8d6 100644 --- a/frontend/app/(main)/admin/page.tsx +++ b/frontend/app/(main)/admin/page.tsx @@ -1,6 +1,4 @@ -import { - Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package -} from "lucide-react"; +import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react"; import Link from "next/link"; import { GlobalFileViewer } from "@/components/GlobalFileViewer"; @@ -9,208 +7,206 @@ import { GlobalFileViewer } from "@/components/GlobalFileViewer"; */ export default function AdminPage() { return ( -
-
- - {/* 주요 관리 기능 */} -
-
-

주요 관리 기능

-

시스템의 핵심 관리 기능들을 제공합니다

-
-
- -
-
-
- -
-
-

사용자 관리

-

사용자 계정 및 권한 관리

-
-
+
+
+ {/* 주요 관리 기능 */} +
+
+

주요 관리 기능

+

시스템의 핵심 관리 기능들을 제공합니다

- +
+ +
+
+
+ +
+
+

사용자 관리

+

사용자 계정 및 권한 관리

+
+
+
+ -
-
-
- + {/*
+
+
+ +
+
+

권한 관리

+

메뉴 및 기능 권한 설정

+
+
-
-

권한 관리

-

메뉴 및 기능 권한 설정

+ +
+
+
+ +
+
+

시스템 설정

+

기본 설정 및 환경 구성

+
+
+ +
+
+
+ +
+
+

통계 및 리포트

+

시스템 사용 현황 분석

+
+
+
*/} + + +
+
+
+ +
+
+

화면관리

+

드래그앤드롭으로 화면 설계 및 관리

+
+
+
+
-
-
-
- -
-
-

시스템 설정

-

기본 설정 및 환경 구성

-
+ {/* 표준 관리 섹션 */} +
+
+

표준 관리

+

시스템 표준 및 컴포넌트를 통합 관리합니다

+
+
+ {/* +
+
+
+ +
+
+

웹타입 관리

+

입력 컴포넌트 웹타입 표준 관리

+
+
+
+ + + +
+
+
+ +
+
+

템플릿 관리

+

화면 디자이너 템플릿 표준 관리

+
+
+
+ */} + + +
+
+
+ +
+
+

테이블 관리

+

데이터베이스 테이블 및 웹타입 매핑

+
+
+
+ + + {/* +
+
+
+ +
+
+

컴포넌트 관리

+

화면 디자이너 컴포넌트 표준 관리

+
+
+
+ */}
-
-
-
- -
-
-

통계 및 리포트

-

시스템 사용 현황 분석

-
+ {/* 빠른 액세스 */} +
+
+

빠른 액세스

+

자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다

+
+
+ +
+
+
+ +
+
+

메뉴 관리

+

시스템 메뉴 및 네비게이션 설정

+
+
+
+ + + +
+
+
+ +
+
+

외부 연결 관리

+

외부 데이터베이스 연결 설정

+
+
+
+ + + +
+
+
+ +
+
+

공통 코드 관리

+

시스템 공통 코드 및 설정

+
+
+
+
- -
-
-
- -
-
-

화면관리

-

드래그앤드롭으로 화면 설계 및 관리

-
-
+ {/* 전역 파일 관리 */} +
+
+

전역 파일 관리

+

모든 페이지에서 업로드된 파일들을 관리합니다

- +
- - {/* 표준 관리 섹션 */} -
-
-

표준 관리

-

시스템 표준 및 컴포넌트를 통합 관리합니다

-
-
- -
-
-
- -
-
-

웹타입 관리

-

입력 컴포넌트 웹타입 표준 관리

-
-
-
- - - -
-
-
- -
-
-

템플릿 관리

-

화면 디자이너 템플릿 표준 관리

-
-
-
- - - -
-
-
- -
-
-

테이블 관리

-

데이터베이스 테이블 및 웹타입 매핑

-
-
-
- - - -
-
-
- -
-
-

컴포넌트 관리

-

화면 디자이너 컴포넌트 표준 관리

-
-
-
- -
-
- - {/* 빠른 액세스 */} -
-
-

빠른 액세스

-

자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다

-
-
- -
-
-
- -
-
-

메뉴 관리

-

시스템 메뉴 및 네비게이션 설정

-
-
-
- - - -
-
-
- -
-
-

외부 연결 관리

-

외부 데이터베이스 연결 설정

-
-
-
- - - -
-
-
- -
-
-

공통 코드 관리

-

시스템 공통 코드 및 설정

-
-
-
- -
-
- - {/* 전역 파일 관리 */} -
-
-

전역 파일 관리

-

모든 페이지에서 업로드된 파일들을 관리합니다

-
- -
- -
); } From a8cbc289f65511de414a7c6165d50f15ea2a364c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 9 Dec 2025 15:12:59 +0900 Subject: [PATCH 12/25] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EC=84=B8=EA=B8=88=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SimpleRepeaterTableComponent.tsx | 218 ++++++++++- .../SimpleRepeaterTableConfigPanel.tsx | 349 +++++++++++++++++- .../components/simple-repeater-table/index.ts | 11 + .../components/simple-repeater-table/types.ts | 112 ++++++ 4 files changed, 683 insertions(+), 7 deletions(-) diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index 3bb81986..ba47d13a 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -1,11 +1,11 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { Trash2, Loader2, X } from "lucide-react"; -import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types"; +import { Trash2, Loader2, X, Plus } from "lucide-react"; +import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig, SummaryFieldConfig } from "./types"; import { cn } from "@/lib/utils"; import { ComponentRendererProps } from "@/types/component"; import { useCalculation } from "./useCalculation"; @@ -21,6 +21,7 @@ export interface SimpleRepeaterTableComponentProps extends ComponentRendererProp readOnly?: boolean; showRowNumber?: boolean; allowDelete?: boolean; + allowAdd?: boolean; maxHeight?: string; } @@ -44,6 +45,7 @@ export function SimpleRepeaterTableComponent({ readOnly: propReadOnly, showRowNumber: propShowRowNumber, allowDelete: propAllowDelete, + allowAdd: propAllowAdd, maxHeight: propMaxHeight, ...props @@ -60,6 +62,13 @@ export function SimpleRepeaterTableComponent({ const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false; const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true; const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true; + const allowAdd = componentConfig?.allowAdd ?? propAllowAdd ?? false; + const addButtonText = componentConfig?.addButtonText || "행 추가"; + const addButtonPosition = componentConfig?.addButtonPosition || "bottom"; + const minRows = componentConfig?.minRows ?? 0; + const maxRows = componentConfig?.maxRows ?? Infinity; + const newRowDefaults = componentConfig?.newRowDefaults || {}; + const summaryConfig = componentConfig?.summaryConfig; const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px"; // value는 formData[columnName] 우선, 없으면 prop 사용 @@ -345,10 +354,137 @@ export function SimpleRepeaterTableComponent({ }; const handleRowDelete = (rowIndex: number) => { + // 최소 행 수 체크 + if (value.length <= minRows) { + return; + } const newData = value.filter((_, i) => i !== rowIndex); handleChange(newData); }; + // 행 추가 함수 + const handleAddRow = () => { + // 최대 행 수 체크 + if (value.length >= maxRows) { + return; + } + + // 새 행 생성 (기본값 적용) + const newRow: Record = { ...newRowDefaults }; + + // 각 컬럼의 기본값 설정 + columns.forEach((col) => { + if (newRow[col.field] === undefined) { + if (col.defaultValue !== undefined) { + newRow[col.field] = col.defaultValue; + } else if (col.type === "number") { + newRow[col.field] = 0; + } else if (col.type === "date") { + newRow[col.field] = new Date().toISOString().split("T")[0]; + } else { + newRow[col.field] = ""; + } + } + }); + + // 계산 필드 적용 + const calculatedRow = calculateRow(newRow); + + const newData = [...value, calculatedRow]; + handleChange(newData); + }; + + // 합계 계산 + const summaryValues = useMemo(() => { + if (!summaryConfig?.enabled || !summaryConfig.fields || value.length === 0) { + return null; + } + + const result: Record = {}; + + // 먼저 기본 집계 함수 계산 + summaryConfig.fields.forEach((field) => { + if (field.formula) return; // 수식 필드는 나중에 처리 + + const values = value.map((row) => { + const val = row[field.field]; + return typeof val === "number" ? val : parseFloat(val) || 0; + }); + + switch (field.type || "sum") { + case "sum": + result[field.field] = values.reduce((a, b) => a + b, 0); + break; + case "avg": + result[field.field] = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + break; + case "count": + result[field.field] = values.length; + break; + case "min": + result[field.field] = Math.min(...values); + break; + case "max": + result[field.field] = Math.max(...values); + break; + default: + result[field.field] = values.reduce((a, b) => a + b, 0); + } + }); + + // 수식 필드 계산 (다른 합계 필드 참조) + summaryConfig.fields.forEach((field) => { + if (!field.formula) return; + + let formula = field.formula; + // 다른 필드 참조 치환 + Object.keys(result).forEach((key) => { + formula = formula.replace(new RegExp(`\\b${key}\\b`, "g"), result[key].toString()); + }); + + try { + result[field.field] = new Function(`return ${formula}`)(); + } catch { + result[field.field] = 0; + } + }); + + return result; + }, [value, summaryConfig]); + + // 합계 값 포맷팅 + const formatSummaryValue = (field: SummaryFieldConfig, value: number): string => { + const decimals = field.decimals ?? 0; + const formatted = value.toFixed(decimals); + + switch (field.format) { + case "currency": + return Number(formatted).toLocaleString() + "원"; + case "percent": + return formatted + "%"; + default: + return Number(formatted).toLocaleString(); + } + }; + + // 행 추가 버튼 컴포넌트 + const AddRowButton = () => { + if (!allowAdd || readOnly || value.length >= maxRows) return null; + + return ( + + ); + }; + const renderCell = ( row: any, column: SimpleRepeaterColumnConfig, @@ -457,8 +593,18 @@ export function SimpleRepeaterTableComponent({ ); } + // 테이블 컬럼 수 계산 + const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0); + return (
+ {/* 상단 행 추가 버튼 */} + {allowAdd && addButtonPosition !== "bottom" && ( +
+ +
+ )} +
- 표시할 데이터가 없습니다 + {allowAdd ? ( +
+ 표시할 데이터가 없습니다 + +
+ ) : ( + "표시할 데이터가 없습니다" + )} ) : ( @@ -517,7 +670,8 @@ export function SimpleRepeaterTableComponent({ variant="ghost" size="sm" onClick={() => handleRowDelete(rowIndex)} - className="h-7 w-7 p-0 text-destructive hover:text-destructive" + disabled={value.length <= minRows} + className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50" > @@ -529,6 +683,58 @@ export function SimpleRepeaterTableComponent({
+ + {/* 합계 표시 */} + {summaryConfig?.enabled && summaryValues && ( +
+
+ {summaryConfig.title && ( +
+ {summaryConfig.title} +
+ )} +
+ {summaryConfig.fields.map((field) => ( +
+ {field.label} + + {formatSummaryValue(field, summaryValues[field.field] || 0)} + +
+ ))} +
+
+
+ )} + + {/* 하단 행 추가 버튼 */} + {allowAdd && addButtonPosition !== "top" && value.length > 0 && ( +
+ + {maxRows !== Infinity && ( + + {value.length} / {maxRows} + + )} +
+ )}
); } diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx index 69b2b597..f0bdd7ac 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx @@ -15,6 +15,8 @@ import { ColumnTargetConfig, InitialDataConfig, DataFilterCondition, + SummaryConfig, + SummaryFieldConfig, } from "./types"; import { tableManagementApi } from "@/lib/api/tableManagement"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -482,6 +484,81 @@ export function SimpleRepeaterTableConfigPanel({

+
+
+ + updateConfig({ allowAdd: checked })} + /> +
+

+ 사용자가 새 행을 추가할 수 있습니다 +

+
+ + {localConfig.allowAdd && ( + <> +
+ + updateConfig({ addButtonText: e.target.value })} + placeholder="행 추가" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + +
+ +
+
+ + updateConfig({ minRows: parseInt(e.target.value) || 0 })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 0이면 제한 없음 +

+
+ +
+ + updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })} + placeholder="무제한" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 비워두면 무제한 +

+
+
+ + )} +
+ {/* 합계 설정 */} +
+
+

합계 설정

+

+ 테이블 하단에 합계를 표시합니다 +

+
+ +
+
+ + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: checked, + fields: localConfig.summaryConfig?.fields || [], + } + })} + /> +
+
+ + {localConfig.summaryConfig?.enabled && ( + <> +
+ + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: true, + title: e.target.value, + fields: localConfig.summaryConfig?.fields || [], + } + })} + placeholder="합계" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + +
+ +
+
+ + +
+ + {localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? ( +
+ {localConfig.summaryConfig.fields.map((field, index) => ( +
+
+ 합계 필드 {index + 1} + +
+ +
+
+ + +
+ +
+ + { + const fields = [...(localConfig.summaryConfig?.fields || [])]; + fields[index] = { ...fields[index], label: e.target.value }; + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: true, + fields, + } + }); + }} + placeholder="합계 라벨" + className="h-8 text-xs" + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+ + { + const fields = [...(localConfig.summaryConfig?.fields || [])]; + fields[index] = { ...fields[index], highlight: checked }; + updateConfig({ + summaryConfig: { + ...localConfig.summaryConfig, + enabled: true, + fields, + } + }); + }} + /> +
+
+ ))} +
+ ) : ( +
+

+ 합계 필드를 추가하세요 +

+
+ )} +
+ +
+

사용 예시

+
+

• 공급가액 합계: supply_amount 필드의 SUM

+

• 세액 합계: tax_amount 필드의 SUM

+

• 총액: supply_amount + tax_amount (수식 필드)

+
+
+ + )} +
+ {/* 사용 안내 */}

SimpleRepeaterTable 사용법:

  • 주어진 데이터를 표시하고 편집하는 경량 테이블입니다
  • -
  • 검색/추가 기능은 없으며, 상위 컴포넌트에서 데이터를 전달받습니다
  • +
  • 행 추가 허용 옵션으로 사용자가 새 행을 추가할 수 있습니다
  • 주로 EditModal과 함께 사용되며, 선택된 데이터를 일괄 수정할 때 유용합니다
  • readOnly 옵션으로 전체 테이블을 읽기 전용으로 만들 수 있습니다
  • 자동 계산 규칙을 통해 수량 * 단가 = 금액 같은 계산을 자동화할 수 있습니다
  • +
  • 합계 설정으로 테이블 하단에 합계/평균 등을 표시할 수 있습니다
diff --git a/frontend/lib/registry/components/simple-repeater-table/index.ts b/frontend/lib/registry/components/simple-repeater-table/index.ts index 0c6457ac..9cb2d3f2 100644 --- a/frontend/lib/registry/components/simple-repeater-table/index.ts +++ b/frontend/lib/registry/components/simple-repeater-table/index.ts @@ -31,6 +31,15 @@ export const SimpleRepeaterTableDefinition = createComponentDefinition({ readOnly: false, showRowNumber: true, allowDelete: true, + allowAdd: false, + addButtonText: "행 추가", + addButtonPosition: "bottom", + minRows: 0, + maxRows: undefined, + summaryConfig: { + enabled: false, + fields: [], + }, maxHeight: "240px", }, defaultSize: { width: 800, height: 400 }, @@ -51,6 +60,8 @@ export type { InitialDataConfig, DataFilterCondition, SourceJoinCondition, + SummaryConfig, + SummaryFieldConfig, } from "./types"; // 컴포넌트 내보내기 diff --git a/frontend/lib/registry/components/simple-repeater-table/types.ts b/frontend/lib/registry/components/simple-repeater-table/types.ts index 8b137891..0ace80aa 100644 --- a/frontend/lib/registry/components/simple-repeater-table/types.ts +++ b/frontend/lib/registry/components/simple-repeater-table/types.ts @@ -1 +1,113 @@ +/** + * SimpleRepeaterTable 타입 정의 + */ +// 컬럼 데이터 소스 설정 +export interface ColumnSourceConfig { + type: "direct" | "join" | "manual"; + sourceTable?: string; + sourceColumn?: string; + joinTable?: string; + joinColumn?: string; + joinKey?: string; + joinRefKey?: string; +} + +// 컬럼 데이터 타겟 설정 +export interface ColumnTargetConfig { + targetTable?: string; + targetColumn?: string; + saveEnabled?: boolean; +} + +// 컬럼 설정 +export interface SimpleRepeaterColumnConfig { + field: string; + label: string; + type?: "text" | "number" | "date" | "select"; + width?: string; + editable?: boolean; + required?: boolean; + calculated?: boolean; + defaultValue?: any; + placeholder?: string; + selectOptions?: { value: string; label: string }[]; + sourceConfig?: ColumnSourceConfig; + targetConfig?: ColumnTargetConfig; +} + +// 계산 규칙 +export interface CalculationRule { + result: string; + formula: string; + dependencies?: string[]; +} + +// 초기 데이터 필터 조건 +export interface DataFilterCondition { + field: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN"; + value?: any; + valueFromField?: string; +} + +// 소스 조인 조건 +export interface SourceJoinCondition { + sourceKey: string; + referenceKey: string; +} + +// 초기 데이터 설정 +export interface InitialDataConfig { + sourceTable: string; + filterConditions?: DataFilterCondition[]; + joinConditions?: SourceJoinCondition[]; +} + +// 합계 필드 설정 +export interface SummaryFieldConfig { + field: string; + label: string; + type?: "sum" | "avg" | "count" | "min" | "max"; + formula?: string; // 다른 합계 필드를 참조하는 계산식 (예: "supply_amount + tax_amount") + format?: "number" | "currency" | "percent"; + decimals?: number; + highlight?: boolean; // 강조 표시 (합계 행) +} + +// 합계 설정 +export interface SummaryConfig { + enabled: boolean; + position?: "bottom" | "bottom-right"; + title?: string; + fields: SummaryFieldConfig[]; +} + +// 메인 Props +export interface SimpleRepeaterTableProps { + // 기본 설정 + columns?: SimpleRepeaterColumnConfig[]; + calculationRules?: CalculationRule[]; + initialDataConfig?: InitialDataConfig; + + // 표시 설정 + readOnly?: boolean; + showRowNumber?: boolean; + allowDelete?: boolean; + maxHeight?: string; + + // 행 추가 설정 + allowAdd?: boolean; + addButtonText?: string; + addButtonPosition?: "top" | "bottom" | "both"; + minRows?: number; + maxRows?: number; + newRowDefaults?: Record; + + // 합계 설정 + summaryConfig?: SummaryConfig; + + // 데이터 + value?: any[]; + onChange?: (newData: any[]) => void; +} From 94b371ca0f28984c320d1f821848851a7a4ef8b1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 9 Dec 2025 16:38:47 +0900 Subject: [PATCH 13/25] =?UTF-8?q?=EA=B3=B5=EC=B0=A8=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=9A=B4=ED=96=89=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20vehicles=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=B6=9C=EB=B0=9C=EC=A7=80/=EB=8F=84=EC=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=9A=B4=ED=96=89=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=8B=9C=20=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 20 ++++++-- frontend/lib/utils/buttonActions.ts | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 97cd2cc1..98606f51 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -427,7 +427,8 @@ export const updateFieldValue = async ( ): Promise => { try { const { companyCode, userId } = req.user as any; - const { tableName, keyField, keyValue, updateField, updateValue } = req.body; + const { tableName, keyField, keyValue, updateField, updateValue } = + req.body; console.log("🔄 [updateFieldValue] 요청:", { tableName, @@ -440,16 +441,27 @@ export const updateFieldValue = async ( }); // 필수 필드 검증 - if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) { + if ( + !tableName || + !keyField || + keyValue === undefined || + !updateField || + updateValue === undefined + ) { return res.status(400).json({ success: false, - message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", + message: + "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)", }); } // SQL 인젝션 방지를 위한 테이블명/컬럼명 검증 const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) { + if ( + !validNamePattern.test(tableName) || + !validNamePattern.test(keyField) || + !validNamePattern.test(updateField) + ) { return res.status(400).json({ success: false, message: "유효하지 않은 테이블명 또는 컬럼명입니다.", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index c5e86849..e0fb66b7 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -3818,6 +3818,7 @@ export class ButtonActionExecutor { const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); if (keyValue) { + // 상태 업데이트 await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, @@ -3826,6 +3827,37 @@ export class ButtonActionExecutor { updateValue: config.trackingStatusOnStart, }); console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart); + + // 🆕 출발지/도착지도 vehicles 테이블에 저장 + if (departure) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: "departure", + updateValue: departure, + }); + console.log("✅ 출발지 저장 완료:", departure); + } catch { + // 컬럼이 없으면 무시 + } + } + + if (arrival) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: "arrival", + updateValue: arrival, + }); + console.log("✅ 도착지 저장 완료:", arrival); + } catch { + // 컬럼이 없으면 무시 + } + } } } catch (statusError) { console.warn("⚠️ 상태 변경 실패:", statusError); @@ -4032,6 +4064,23 @@ export class ButtonActionExecutor { updateValue: effectiveConfig.trackingStatusOnStop, }); console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop); + + // 🆕 운행 종료 시 vehicles 테이블의 출발지/도착지/위도/경도를 null로 초기화 + const fieldsToReset = ["departure", "arrival", "latitude", "longitude"]; + for (const field of fieldsToReset) { + try { + await apiClient.put("/dynamic-form/update-field", { + tableName: statusTableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: null, + }); + } catch { + // 컬럼이 없으면 무시 + } + } + console.log("✅ 출발지/도착지/위도/경도 초기화 완료"); } } catch (statusError) { console.warn("⚠️ 상태 변경 실패:", statusError); From 36a7529da210a9df1c11492ccb7ae716f8190d14 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 9 Dec 2025 16:54:47 +0900 Subject: [PATCH 14/25] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A6=9D?= =?UTF-8?q?=EC=8B=9D=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SimpleRepeaterTableConfigPanel.tsx | 156 +++++++++--------- 1 file changed, 80 insertions(+), 76 deletions(-) diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx index f0bdd7ac..396d831f 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx @@ -585,21 +585,29 @@ export function SimpleRepeaterTableConfigPanel({

- 초기 데이터를 조회할 테이블 (예: sales_order_mng) + 선택 안 하면 빈 테이블로 시작합니다 (새 데이터 입력용)

@@ -1079,48 +1087,71 @@ export function SimpleRepeaterTableConfigPanel({ )}
- {/* 🆕 데이터 타겟 설정 (어디에 저장할지) */} -
-
-
- + {/* 🆕 데이터 타겟 설정 - 부모-자식 모드면 숨김 */} + {localConfig.parentChildConfig?.enabled ? ( + // 부모-자식 모드: 간단한 안내만 표시 +
+
+

+ 부모-자식 모드 +
+ → {localConfig.parentChildConfig.childTable || "자식 테이블"}.{col.field || "필드명"} 에 저장 +

+
+ ) : ( + // 일반 모드: 타겟 설정 (선택사항) +
+
+
+ +
-
- - -

- 이 컬럼의 값을 저장할 테이블 -

-
+
+ + +

+ 선택 안 하면 이 컬럼은 저장되지 않습니다 +

+
- {col.targetConfig?.targetTable && ( - <> + {col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" && (
updateColumn(index, { targetConfig: { ...col.targetConfig, @@ -1129,37 +1160,10 @@ export function SimpleRepeaterTableConfigPanel({ })} showTableName={true} /> -

- 저장할 컬럼명 -

- -
-
- - updateColumn(index, { - targetConfig: { - ...col.targetConfig, - saveEnabled: checked - } - })} - /> -
-

- 비활성화 시 저장하지 않음 (표시 전용) -

-
- - {col.targetConfig.targetTable && col.targetConfig.targetColumn && ( -
- 저장: {col.targetConfig.targetTable}.{col.targetConfig.targetColumn} -
- )} - - )} -
+ )} +
+ )} {/* 편집 가능 여부 */}
From 93ec294be303a1442cfb8e7546f4cc0b66c353f5 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 9 Dec 2025 18:26:36 +0900 Subject: [PATCH 15/25] =?UTF-8?q?=ED=88=B4=EB=B0=94=20=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table-list/TableListComponent.tsx | 342 +++++++++--------- .../table-list/TableListConfigPanel.tsx | 67 ++++ .../registry/components/table-list/types.ts | 16 + 3 files changed, 263 insertions(+), 162 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 982a7f8c..5073197d 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4675,19 +4675,21 @@ export const TableListComponent: React.FC = ({ {/* 새로고침 버튼 */} - + {(tableConfig.toolbar?.showRefresh ?? true) && ( + + )}
); - }, [tableConfig.pagination, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); + }, [tableConfig.pagination, tableConfig.toolbar?.showRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // 렌더링 @@ -4790,57 +4792,67 @@ export const TableListComponent: React.FC = ({ {/* 🆕 DevExpress 스타일 기능 툴바 */}
{/* 편집 모드 토글 */} -
- -
+ {(tableConfig.toolbar?.showEditMode ?? true) && ( +
+ +
+ )} {/* 내보내기 버튼들 */} -
- - -
+ {((tableConfig.toolbar?.showExcel ?? true) || (tableConfig.toolbar?.showPdf ?? true)) && ( +
+ {(tableConfig.toolbar?.showExcel ?? true) && ( + + )} + {(tableConfig.toolbar?.showPdf ?? true) && ( + + )} +
+ )} {/* 복사 버튼 */} -
- -
+ {(tableConfig.toolbar?.showCopy ?? true) && ( +
+ +
+ )} {/* 선택 정보 */} {selectedRows.size > 0 && ( @@ -4861,124 +4873,130 @@ export const TableListComponent: React.FC = ({ )} {/* 🆕 통합 검색 패널 */} -
- {isSearchPanelOpen ? ( -
- setGlobalSearchTerm(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - executeGlobalSearch(globalSearchTerm); - } else if (e.key === "Escape") { - clearGlobalSearch(); - } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { - e.preventDefault(); - if (e.shiftKey) { - goToPrevSearchResult(); - } else { - goToNextSearchResult(); + {(tableConfig.toolbar?.showSearch ?? true) && ( +
+ {isSearchPanelOpen ? ( +
+ setGlobalSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeGlobalSearch(globalSearchTerm); + } else if (e.key === "Escape") { + clearGlobalSearch(); + } else if (e.key === "F3" || (e.key === "g" && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + if (e.shiftKey) { + goToPrevSearchResult(); + } else { + goToNextSearchResult(); + } } - } - }} - 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 && ( - - {searchHighlights.size}개 + }} + 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 && ( + + {searchHighlights.size}개 + + )} + + + +
+ ) : ( + + )} +
+ )} + + {/* 🆕 Filter Builder (고급 필터) 버튼 */} + {(tableConfig.toolbar?.showFilter ?? true) && ( +
+ + {activeFilterCount > 0 && ( - - -
- ) : ( - - )} -
- - {/* 🆕 Filter Builder (고급 필터) 버튼 */} -
- - {activeFilterCount > 0 && ( - - )} -
+
+ )} {/* 새로고침 */} -
- -
+ {(tableConfig.toolbar?.showRefresh ?? true) && ( +
+ +
+ )}
{/* 🆕 배치 편집 툴바 */} diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index 3ea73aa9..bd391356 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -765,6 +765,73 @@ export const TableListConfigPanel: React.FC = ({
+ {/* 툴바 버튼 설정 */} +
+
+

툴바 버튼 설정

+

테이블 상단에 표시할 버튼을 선택합니다

+
+
+
+
+ handleNestedChange("toolbar", "showEditMode", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showExcel", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showPdf", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showCopy", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showSearch", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showFilter", checked)} + /> + +
+
+ handleNestedChange("toolbar", "showRefresh", checked)} + /> + +
+
+
+ {/* 체크박스 설정 */}
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index a619baa0..f4695b20 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -164,6 +164,19 @@ export interface PaginationConfig { pageSizeOptions: number[]; } +/** + * 툴바 버튼 표시 설정 + */ +export interface ToolbarConfig { + showEditMode?: boolean; // 즉시 저장/배치 모드 버튼 + showExcel?: boolean; // Excel 내보내기 버튼 + showPdf?: boolean; // PDF 내보내기 버튼 + showCopy?: boolean; // 복사 버튼 + showSearch?: boolean; // 검색 버튼 + showFilter?: boolean; // 필터 버튼 + showRefresh?: boolean; // 새로고침 버튼 +} + /** * 체크박스 설정 */ @@ -259,6 +272,9 @@ export interface TableListConfig extends ComponentConfig { autoLoad: boolean; refreshInterval?: number; // 초 단위 + // 🆕 툴바 버튼 표시 설정 + toolbar?: ToolbarConfig; + // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; From dde65a2d1ed6de1b10067cceebc0461cb407c9df Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 09:17:11 +0900 Subject: [PATCH 16/25] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=88=B4?= =?UTF-8?q?=EB=B0=94=20=EC=84=A4=EC=A0=95=20=EC=A0=9C=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=90=A9=EB=8B=88=EB=8B=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/table-list/TableListComponent.tsx | 6 +++--- .../components/table-list/TableListConfigPanel.tsx | 10 +++++++++- frontend/lib/registry/components/table-list/types.ts | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 5073197d..9fec8fc5 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4674,8 +4674,8 @@ export const TableListComponent: React.FC = ({ - {/* 새로고침 버튼 */} - {(tableConfig.toolbar?.showRefresh ?? true) && ( + {/* 새로고침 버튼 (하단 페이지네이션) */} + {(tableConfig.toolbar?.showPaginationRefresh ?? true) && (
); - }, [tableConfig.pagination, tableConfig.toolbar?.showRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); + }, [tableConfig.pagination, tableConfig.toolbar?.showPaginationRefresh, isDesignMode, currentPage, totalPages, totalItems, loading, selectedRows.size, exportToExcel, exportToPdf]); // ======================================== // 렌더링 diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx index bd391356..fbdaf6da 100644 --- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx @@ -827,7 +827,15 @@ export const TableListConfigPanel: React.FC = ({ checked={config.toolbar?.showRefresh ?? true} onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)} /> - + +
+
+ handleNestedChange("toolbar", "showPaginationRefresh", checked)} + /> +
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts index f4695b20..7adb87d1 100644 --- a/frontend/lib/registry/components/table-list/types.ts +++ b/frontend/lib/registry/components/table-list/types.ts @@ -174,7 +174,8 @@ export interface ToolbarConfig { showCopy?: boolean; // 복사 버튼 showSearch?: boolean; // 검색 버튼 showFilter?: boolean; // 필터 버튼 - showRefresh?: boolean; // 새로고침 버튼 + showRefresh?: boolean; // 상단 툴바 새로고침 버튼 + showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼 } /** From 3608d9f9c37fe581e023627f970db3437296fe99 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 10:27:54 +0900 Subject: [PATCH 17/25] =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SimpleRepeaterTableComponent.tsx | 23 +++++++++++++------ .../SimpleRepeaterTableConfigPanel.tsx | 12 ++++++---- .../SimpleRepeaterTableRenderer.tsx | 2 +- .../simple-repeater-table/useCalculation.ts | 3 ++- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index ba47d13a..84955c3e 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -48,8 +48,17 @@ export function SimpleRepeaterTableComponent({ allowAdd: propAllowAdd, maxHeight: propMaxHeight, + // DOM에 전달되면 안 되는 props 명시적 제거 (부모에서 전달될 수 있음) + initialData: _initialData, + originalData: _originalData, + groupedData: _groupedData, + ...props -}: SimpleRepeaterTableComponentProps) { +}: SimpleRepeaterTableComponentProps & { + initialData?: any; + originalData?: any; + groupedData?: any; +}) { // config 또는 component.config 또는 개별 prop 우선순위로 병합 const componentConfig = { ...config, @@ -265,7 +274,7 @@ export function SimpleRepeaterTableComponent({ useEffect(() => { const handleSaveRequest = async (event: Event) => { if (value.length === 0) { - console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음"); + // console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음"); return; } @@ -306,7 +315,7 @@ export function SimpleRepeaterTableComponent({ }); }); - console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable); + // console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable); // CustomEvent의 detail에 테이블별 데이터 추가 if (event instanceof CustomEvent && event.detail) { @@ -319,10 +328,10 @@ export function SimpleRepeaterTableComponent({ })); }); - console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", { - tables: Object.keys(dataByTable), - totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0), - }); + // console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", { + // tables: Object.keys(dataByTable), + // totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0), + // }); } // 기존 onFormDataChange도 호출 (호환성) diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx index 396d831f..41e70e08 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPanel.tsx @@ -1316,11 +1316,13 @@ export function SimpleRepeaterTableConfigPanel({ - {(localConfig.columns || []).map((col, colIndex) => ( - - {col.label} ({col.field || '미설정'}) - - ))} + {(localConfig.columns || []) + .filter((col) => col.field && col.field.trim() !== "") + .map((col, colIndex) => ( + + {col.label} ({col.field}) + + ))}

diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx index 13e75743..31a83548 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableRenderer.tsx @@ -9,7 +9,7 @@ import { ComponentRendererProps } from "@/types/component"; // 컴포넌트 자동 등록 ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition); -console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료"); +// console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료"); export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) { return ; diff --git a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts index 8a0fdba5..7cb66219 100644 --- a/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts +++ b/frontend/lib/registry/components/simple-repeater-table/useCalculation.ts @@ -30,7 +30,8 @@ export function useCalculation(calculationRules: CalculationRule[] = []) { // 결과 필드는 제외 if (dep === rule.result) continue; - const value = parseFloat(row[dep]) || 0; + // 이전 계산 결과(updatedRow)를 우선 사용, 없으면 원본(row) 사용 + const value = parseFloat(updatedRow[dep] ?? row[dep]) || 0; // 정확한 필드명만 대체 (단어 경계 사용) formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString()); } From c64c94c07b5a2b6a9a3dbb00a72a20b5fc7a4b3c Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 13:48:57 +0900 Subject: [PATCH 18/25] =?UTF-8?q?=EC=B5=9C=EA=B7=BC=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=9C=20=EB=82=B4=EC=97=AD=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 222 +++++++++++++++++- 1 file changed, 219 insertions(+), 3 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 48545281..c1553b38 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -103,6 +103,13 @@ 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 [tripInfo, setTripInfo] = useState>({}); + const [tripInfoLoading, setTripInfoLoading] = useState(null); + + // Popup 열림 상태 (자동 새로고침 일시 중지용) + const [isPopupOpen, setIsPopupOpen] = useState(false); + // 지역 필터 상태 const [selectedRegion, setSelectedRegion] = useState("all"); @@ -187,6 +194,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setRoutePoints([]); }, []); + // 공차/운행 정보 로드 함수 + const loadTripInfo = useCallback(async (identifier: string) => { + if (!identifier || tripInfo[identifier]) { + return; // 이미 로드됨 + } + + setTripInfoLoading(identifier); + + try { + // user_id 또는 vehicle_number로 조회 + const query = `SELECT + id, vehicle_number, user_id, + last_trip_start, last_trip_end, last_trip_distance, last_trip_time, + last_empty_start, last_empty_end, last_empty_distance, last_empty_time, + departure, arrival, status + FROM vehicles + WHERE user_id = '${identifier}' + OR vehicle_number = '${identifier}' + LIMIT 1`; + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + setTripInfo((prev) => ({ + ...prev, + [identifier]: result.data.rows[0], + })); + } + } + } catch (err) { + console.error("공차/운행 정보 로드 실패:", err); + } + + setTripInfoLoading(null); + }, [tripInfo]); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -1135,14 +1187,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } const intervalId = setInterval(() => { - loadMultipleDataSources(); + // Popup이 열려있으면 자동 새로고침 건너뛰기 + if (!isPopupOpen) { + loadMultipleDataSources(); + } }, refreshInterval * 1000); return () => { clearInterval(intervalId); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSources, element?.chartConfig?.refreshInterval]); + }, [dataSources, element?.chartConfig?.refreshInterval, isPopupOpen]); // 타일맵 URL (VWorld 한국 지도) const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; @@ -1390,6 +1445,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { fillOpacity: 0.3, weight: 2, }} + eventHandlers={{ + popupopen: () => setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} >

@@ -1621,7 +1680,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } return ( - + setIsPopupOpen(true), + popupclose: () => setIsPopupOpen(false), + }} + >
{/* 데이터 소스명만 표시 */} @@ -1732,6 +1799,155 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } })()} + {/* 공차/운행 정보 (동적 로딩) */} + {(() => { + try { + const parsed = JSON.parse(marker.description || "{}"); + + // 식별자 찾기 (user_id 또는 vehicle_number) + const identifier = parsed.user_id || parsed.userId || parsed.vehicle_number || + parsed.vehicleNumber || parsed.plate_no || parsed.plateNo || + parsed.car_number || parsed.carNumber || marker.name; + + if (!identifier) return null; + + // 동적으로 로드된 정보 또는 marker.description에서 가져온 정보 사용 + const info = tripInfo[identifier] || parsed; + + // 공차 정보가 있는지 확인 + const hasEmptyTripInfo = info.last_empty_start || info.last_empty_end || + info.last_empty_distance || info.last_empty_time; + // 운행 정보가 있는지 확인 + const hasTripInfo = info.last_trip_start || info.last_trip_end || + info.last_trip_distance || info.last_trip_time; + + // 날짜/시간 포맷팅 함수 + const formatDateTime = (dateStr: string) => { + if (!dateStr) return "-"; + try { + const date = new Date(dateStr); + return date.toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } + }; + + // 거리 포맷팅 (km) + const formatDistance = (dist: number | string) => { + if (dist === null || dist === undefined) return "-"; + const num = typeof dist === "string" ? parseFloat(dist) : dist; + if (isNaN(num)) return "-"; + return `${num.toFixed(1)} km`; + }; + + // 시간 포맷팅 (분) + const formatTime = (minutes: number | string) => { + if (minutes === null || minutes === undefined) return "-"; + const num = typeof minutes === "string" ? parseInt(minutes) : minutes; + if (isNaN(num)) return "-"; + if (num < 60) return `${num}분`; + const hours = Math.floor(num / 60); + const mins = num % 60; + return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; + }; + + // 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시 + if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) { + return ( +
+ +
+ ); + } + + // 데이터가 없으면 표시 안 함 + if (!hasEmptyTripInfo && !hasTripInfo) return null; + + return ( +
+ {/* 운행 정보 */} + {hasTripInfo && ( +
+
🚛 최근 운행
+
+ {(info.last_trip_start || info.last_trip_end) && ( +
+ 시간:{" "} + {formatDateTime(info.last_trip_start)} ~ {formatDateTime(info.last_trip_end)} +
+ )} +
+ {info.last_trip_distance !== undefined && info.last_trip_distance !== null && ( + + 거리:{" "} + {formatDistance(info.last_trip_distance)} + + )} + {info.last_trip_time !== undefined && info.last_trip_time !== null && ( + + 소요:{" "} + {formatTime(info.last_trip_time)} + + )} +
+ {/* 출발지/도착지 */} + {(info.departure || info.arrival) && ( +
+ {info.departure && 출발: {info.departure}} + {info.departure && info.arrival && " → "} + {info.arrival && 도착: {info.arrival}} +
+ )} +
+
+ )} + + {/* 공차 정보 */} + {hasEmptyTripInfo && ( +
+
📦 최근 공차
+
+ {(info.last_empty_start || info.last_empty_end) && ( +
+ 시간:{" "} + {formatDateTime(info.last_empty_start)} ~ {formatDateTime(info.last_empty_end)} +
+ )} +
+ {info.last_empty_distance !== undefined && info.last_empty_distance !== null && ( + + 거리:{" "} + {formatDistance(info.last_empty_distance)} + + )} + {info.last_empty_time !== undefined && info.last_empty_time !== null && ( + + 소요:{" "} + {formatTime(info.last_empty_time)} + + )} +
+
+
+ )} +
+ ); + } catch { + return null; + } + })()} + {/* 좌표 */}
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} From c7ae04859db75c23efa6ba3eafb3da6b14b1555a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 10 Dec 2025 14:28:11 +0900 Subject: [PATCH 19/25] =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EA=B8=B0=EB=B0=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Primary=20Key=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screen/widgets/FlowWidget.tsx | 87 +++++++++++-------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 50ad0343..5564a14d 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -130,7 +130,7 @@ export function FlowWidget({ const [stepData, setStepData] = useState([]); const [stepDataColumns, setStepDataColumns] = useState([]); const [stepDataLoading, setStepDataLoading] = useState(false); - const [selectedRows, setSelectedRows] = useState>(new Set()); + const [selectedRows, setSelectedRows] = useState>(new Set()); // Primary Key 값으로 선택 관리 const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 // 🆕 검색 필터 관련 상태 @@ -753,25 +753,35 @@ export function FlowWidget({ } }; - // 체크박스 토글 - const toggleRowSelection = (rowIndex: number) => { + // Primary Key 컬럼명 (플로우 정의에서 가져오거나 기본값 id) + const primaryKeyColumn = flowData?.primaryKey || "id"; + + // 행의 Primary Key 값 가져오기 + const getRowKey = useCallback((row: any): string => { + const keyValue = row[primaryKeyColumn] || row.id; + return String(keyValue); + }, [primaryKeyColumn]); + + // 체크박스 토글 (Primary Key 기반) + const toggleRowSelection = (row: any) => { // 프리뷰 모드에서는 행 선택 차단 if (isPreviewMode) { return; } + const rowKey = getRowKey(row); const newSelected = new Set(selectedRows); - if (newSelected.has(rowIndex)) { - newSelected.delete(rowIndex); + if (newSelected.has(rowKey)) { + newSelected.delete(rowKey); } else { - newSelected.add(rowIndex); + newSelected.add(rowKey); } setSelectedRows(newSelected); - // 선택된 데이터를 상위로 전달 - const selectedData = Array.from(newSelected).map((index) => stepData[index]); + // 선택된 데이터를 상위로 전달 (stepData에서 선택된 행들 찾기) + const selectedData = stepData.filter((r) => newSelected.has(getRowKey(r))); console.log("🌊 FlowWidget - 체크박스 토글, 상위로 전달:", { - rowIndex, + rowKey, newSelectedSize: newSelected.size, selectedData, selectedStepId, @@ -780,18 +790,18 @@ export function FlowWidget({ onSelectedDataChange?.(selectedData, selectedStepId); }; - // 전체 선택/해제 + // 전체 선택/해제 (Primary Key 기반) const toggleAllRows = () => { - let newSelected: Set; + let newSelected: Set; if (selectedRows.size === stepData.length) { newSelected = new Set(); } else { - newSelected = new Set(stepData.map((_, index) => index)); + newSelected = new Set(stepData.map((row) => getRowKey(row))); } setSelectedRows(newSelected); // 선택된 데이터를 상위로 전달 - const selectedData = Array.from(newSelected).map((index) => stepData[index]); + const selectedData = stepData.filter((row) => newSelected.has(getRowKey(row))); onSelectedDataChange?.(selectedData, selectedStepId); }; @@ -951,36 +961,41 @@ export function FlowWidget({ return formatValue(value); }, []); - // 🆕 전체 선택 핸들러 + // 🆕 전체 선택 핸들러 (Primary Key 기반) const handleSelectAll = useCallback((checked: boolean) => { if (checked) { - const allIndices = new Set(sortedDisplayData.map((_, idx) => idx)); - setSelectedRows(allIndices); + const allKeys = new Set(sortedDisplayData.map((row) => getRowKey(row))); + setSelectedRows(allKeys); + // 선택된 데이터를 상위로 전달 + onSelectedDataChange?.(sortedDisplayData, selectedStepId); } else { setSelectedRows(new Set()); + onSelectedDataChange?.([], selectedStepId); } - }, [sortedDisplayData]); + }, [sortedDisplayData, getRowKey, onSelectedDataChange, selectedStepId]); // 🆕 행 클릭 핸들러 const handleRowClick = useCallback((row: any) => { // 필요 시 행 클릭 로직 추가 }, []); - // 🆕 체크박스 셀 렌더링 - const renderCheckboxCell = useCallback((row: any, index: number) => { + // 🆕 체크박스 셀 렌더링 (Primary Key 기반) + // index 파라미터는 SingleTableWithSticky 인터페이스 호환을 위해 유지하지만 사용하지 않음 + const renderCheckboxCell = useCallback((row: any, _index: number) => { + const rowKey = getRowKey(row); return ( toggleRowSelection(index)} + checked={selectedRows.has(rowKey)} + onCheckedChange={() => toggleRowSelection(row)} /> ); - }, [selectedRows, toggleRowSelection]); + }, [selectedRows, toggleRowSelection, getRowKey]); - // 🆕 Excel 내보내기 + // 🆕 Excel 내보내기 (Primary Key 기반) const exportToExcel = useCallback(() => { try { const exportData = selectedRows.size > 0 - ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : sortedDisplayData; if (exportData.length === 0) { @@ -1010,13 +1025,13 @@ export function FlowWidget({ console.error("Excel 내보내기 오류:", error); toast.error("Excel 내보내기에 실패했습니다."); } - }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName]); + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); // 🆕 PDF 내보내기 (html2canvas 사용으로 한글 지원) const exportToPdf = useCallback(async () => { try { const exportData = selectedRows.size > 0 - ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : sortedDisplayData; if (exportData.length === 0) { @@ -1175,13 +1190,13 @@ export function FlowWidget({ console.error("PDF 내보내기 오류:", error); toast.error("PDF 내보내기에 실패했습니다.", { id: "pdf-export" }); } - }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName]); + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); - // 🆕 복사 기능 + // 🆕 복사 기능 (Primary Key 기반) const handleCopy = useCallback(() => { try { const copyData = selectedRows.size > 0 - ? sortedDisplayData.filter((_, idx) => selectedRows.has(idx)) + ? sortedDisplayData.filter((row) => selectedRows.has(getRowKey(row))) : []; if (copyData.length === 0) { @@ -1203,7 +1218,7 @@ export function FlowWidget({ console.error("복사 오류:", error); toast.error("복사에 실패했습니다."); } - }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels]); + }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]); // 🆕 통합 검색 실행 const executeGlobalSearch = useCallback((term: string) => { @@ -1828,15 +1843,15 @@ export function FlowWidget({
{allowDataMove && (
선택 toggleRowSelection(actualIndex)} + checked={selectedRows.has(getRowKey(row))} + onCheckedChange={() => toggleRowSelection(row)} />
)} @@ -1919,13 +1934,13 @@ export function FlowWidget({ return ( {allowDataMove && ( toggleRowSelection(actualIndex)} + checked={selectedRows.has(getRowKey(row))} + onCheckedChange={() => toggleRowSelection(row)} /> )} From d7e96327a7695bdd784648adfedb71e515157f92 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 10 Dec 2025 15:11:46 +0900 Subject: [PATCH 20/25] =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=EC=9D=98=20=EC=9E=91=EC=97=85=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=8A=A4=ED=83=80=EC=9D=BC=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/common/TableHistoryModal.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx index 6e552e21..91299457 100644 --- a/frontend/components/common/TableHistoryModal.tsx +++ b/frontend/components/common/TableHistoryModal.tsx @@ -115,17 +115,13 @@ export function TableHistoryModal({ const getOperationBadge = (type: string) => { switch (type) { case "INSERT": - return 추가; + return 추가; case "UPDATE": - return 수정; + return 수정; case "DELETE": - return 삭제; + return 삭제; default: - return ( - - {type} - - ); + return {type}; } }; From f75c3e43ed8717bcc78f6dd8ddd1e9aa44325971 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 15:15:06 +0900 Subject: [PATCH 21/25] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/types.ts | 41 ++ .../widget-sections/ListWidgetSection.tsx | 460 +++++++++++++++++- .../admin/dashboard/widgets/ListWidget.tsx | 279 ++++++++++- .../dashboard/widgets/ListTestWidget.tsx | 279 ++++++++++- 4 files changed, 1049 insertions(+), 10 deletions(-) diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index bc52ecb8..51f3bf7b 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -379,6 +379,47 @@ export interface ListWidgetConfig { stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용) compactMode: boolean; // 압축 모드 (기본: false) cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3) + // 행 클릭 팝업 설정 + rowDetailPopup?: RowDetailPopupConfig; +} + +// 행 상세 팝업 설정 +export interface RowDetailPopupConfig { + enabled: boolean; // 팝업 활성화 여부 + title?: string; // 팝업 제목 (기본: "상세 정보") + // 추가 데이터 조회 설정 + additionalQuery?: { + enabled: boolean; + tableName: string; // 조회할 테이블명 (예: vehicles) + matchColumn: string; // 매칭할 컬럼 (예: id) + sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일) + // 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시) + displayColumns?: DisplayColumnConfig[]; + }; +} + +// 표시 컬럼 설정 +export interface DisplayColumnConfig { + column: string; // DB 컬럼명 + label: string; // 표시 라벨 (사용자 정의) + // 필드 그룹 설정 + fieldGroups?: FieldGroup[]; +} + +// 필드 그룹 (팝업 내 섹션) +export interface FieldGroup { + id: string; + title: string; // 그룹 제목 (예: "운행 정보") + icon?: string; // 아이콘 (예: "truck", "clock") + color?: "blue" | "orange" | "green" | "red" | "purple" | "gray"; + fields: FieldConfig[]; +} + +// 필드 설정 +export interface FieldConfig { + column: string; // DB 컬럼명 + label: string; // 표시 라벨 + format?: "text" | "number" | "date" | "datetime" | "currency" | "boolean" | "distance" | "duration"; } // 리스트 컬럼 diff --git a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx index 2e84f123..b10057cf 100644 --- a/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx +++ b/frontend/components/admin/dashboard/widget-sections/ListWidgetSection.tsx @@ -1,10 +1,17 @@ "use client"; -import React from "react"; -import { ListWidgetConfig, QueryResult } from "../types"; +import React, { useState } from "react"; +import { ListWidgetConfig, QueryResult, FieldGroup, FieldConfig, DisplayColumnConfig } from "../types"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UnifiedColumnEditor } from "../widgets/list-widget/UnifiedColumnEditor"; import { ListTableOptions } from "../widgets/list-widget/ListTableOptions"; +import { Plus, Trash2, ChevronDown, ChevronUp, X, Check } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; interface ListWidgetSectionProps { queryResult: QueryResult | null; @@ -16,8 +23,91 @@ interface ListWidgetSectionProps { * 리스트 위젯 설정 섹션 * - 컬럼 설정 * - 테이블 옵션 + * - 행 클릭 팝업 설정 */ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListWidgetSectionProps) { + const [expandedGroups, setExpandedGroups] = useState>({}); + + // 팝업 설정 초기화 + const popupConfig = config.rowDetailPopup || { + enabled: false, + title: "상세 정보", + additionalQuery: { enabled: false, tableName: "", matchColumn: "" }, + fieldGroups: [], + }; + + // 팝업 설정 업데이트 헬퍼 + const updatePopupConfig = (updates: Partial) => { + onConfigChange({ + rowDetailPopup: { ...popupConfig, ...updates }, + }); + }; + + // 필드 그룹 추가 + const addFieldGroup = () => { + const newGroup: FieldGroup = { + id: `group-${Date.now()}`, + title: "새 그룹", + icon: "info", + color: "gray", + fields: [], + }; + updatePopupConfig({ + fieldGroups: [...(popupConfig.fieldGroups || []), newGroup], + }); + }; + + // 필드 그룹 삭제 + const removeFieldGroup = (groupId: string) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).filter((g) => g.id !== groupId), + }); + }; + + // 필드 그룹 업데이트 + const updateFieldGroup = (groupId: string, updates: Partial) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => (g.id === groupId ? { ...g, ...updates } : g)), + }); + }; + + // 필드 추가 + const addField = (groupId: string) => { + const newField: FieldConfig = { + column: "", + label: "", + format: "text", + }; + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: [...g.fields, newField] } : g, + ), + }); + }; + + // 필드 삭제 + const removeField = (groupId: string, fieldIndex: number) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: g.fields.filter((_, i) => i !== fieldIndex) } : g, + ), + }); + }; + + // 필드 업데이트 + const updateField = (groupId: string, fieldIndex: number, updates: Partial) => { + updatePopupConfig({ + fieldGroups: (popupConfig.fieldGroups || []).map((g) => + g.id === groupId ? { ...g, fields: g.fields.map((f, i) => (i === fieldIndex ? { ...f, ...updates } : f)) } : g, + ), + }); + }; + + // 그룹 확장/축소 토글 + const toggleGroupExpand = (groupId: string) => { + setExpandedGroups((prev) => ({ ...prev, [groupId]: !prev[groupId] })); + }; + return (
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */} @@ -35,6 +125,372 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
)} + + {/* 행 클릭 팝업 설정 */} +
+
+ + updatePopupConfig({ enabled })} + aria-label="행 클릭 팝업 활성화" + /> +
+ + {popupConfig.enabled && ( +
+ {/* 팝업 제목 */} +
+ + updatePopupConfig({ title: e.target.value })} + placeholder="상세 정보" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 추가 데이터 조회 설정 */} +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" }, + }) + } + aria-label="추가 데이터 조회 활성화" + /> +
+ + {popupConfig.additionalQuery?.enabled && ( +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value }, + }) + } + placeholder="vehicles" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value }, + }) + } + placeholder="id" + className="mt-1 h-8 text-xs" + /> +
+
+ + + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value }, + }) + } + placeholder="비워두면 매칭 컬럼과 동일" + className="mt-1 h-8 text-xs" + /> +
+ + {/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */} +
+ + + + + + +
+ 컬럼 선택 + +
+
+ {/* 쿼리 결과 컬럼 목록 */} + {queryResult?.columns.map((col) => { + const currentColumns = popupConfig.additionalQuery?.displayColumns || []; + const existingConfig = currentColumns.find((c) => + typeof c === 'object' ? c.column === col : c === col + ); + const isSelected = !!existingConfig; + return ( +
{ + const newColumns = isSelected + ? currentColumns.filter((c) => + typeof c === 'object' ? c.column !== col : c !== col + ) + : [...currentColumns, { column: col, label: col } as DisplayColumnConfig]; + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns }, + }); + }} + > + + {col} +
+ ); + })} + {(!queryResult?.columns || queryResult.columns.length === 0) && ( +

+ 쿼리를 먼저 실행해주세요 +

+ )} +
+
+
+

비워두면 모든 컬럼이 표시됩니다

+ + {/* 선택된 컬럼 라벨 편집 */} + {(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && ( +
+ +
+ {popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return ( +
+ + {column} + + { + const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])]; + newColumns[index] = { column, label: e.target.value }; + updatePopupConfig({ + additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns }, + }); + }} + placeholder="표시 라벨" + className="h-7 flex-1 text-xs" + /> + +
+ ); + })} +
+
+ )} +
+
+ )} +
+ + {/* 필드 그룹 설정 */} +
+
+ + +
+

설정하지 않으면 모든 필드가 자동으로 표시됩니다.

+ + {/* 필드 그룹 목록 */} + {(popupConfig.fieldGroups || []).map((group) => ( +
+ {/* 그룹 헤더 */} +
+ + +
+ + {/* 그룹 상세 (확장 시) */} + {expandedGroups[group.id] && ( +
+ {/* 그룹 제목 */} +
+
+ + updateFieldGroup(group.id, { title: e.target.value })} + className="mt-1 h-7 text-xs" + /> +
+
+ + +
+
+ + {/* 아이콘 */} +
+ + +
+ + {/* 필드 목록 */} +
+
+ + +
+ + {group.fields.map((field, fieldIndex) => ( +
+ updateField(group.id, fieldIndex, { column: e.target.value })} + placeholder="컬럼명" + className="h-6 flex-1 text-xs" + /> + updateField(group.id, fieldIndex, { label: e.target.value })} + placeholder="라벨" + className="h-6 flex-1 text-xs" + /> + + +
+ ))} +
+
+ )} +
+ ))} +
+
+ )} +
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 8193aea4..befb1286 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -1,11 +1,20 @@ "use client"; -import React, { useState, useEffect } from "react"; -import { DashboardElement, QueryResult, ListWidgetConfig } from "../types"; +import React, { useState, useEffect, useCallback } from "react"; +import { DashboardElement, QueryResult, ListWidgetConfig, FieldGroup } from "../types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; +import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -24,6 +33,12 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + // 행 상세 팝업 상태 + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -36,6 +51,215 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { cardColumns: 3, }; + // 행 클릭 핸들러 - 팝업 열기 + const handleRowClick = useCallback( + async (row: Record) => { + // 팝업이 비활성화되어 있으면 무시 + if (!config.rowDetailPopup?.enabled) return; + + setDetailPopupData(row); + setDetailPopupOpen(true); + setAdditionalDetailData(null); + setDetailPopupLoading(false); + + // 추가 데이터 조회 설정이 있으면 실행 + const additionalQuery = config.rowDetailPopup?.additionalQuery; + if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { + const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; + const matchValue = row[sourceColumn]; + + if (matchValue !== undefined && matchValue !== null) { + setDetailPopupLoading(true); + try { + const query = ` + SELECT * + FROM ${additionalQuery.tableName} + WHERE ${additionalQuery.matchColumn} = '${matchValue}' + LIMIT 1; + `; + + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(query); + + if (result.success && result.rows.length > 0) { + setAdditionalDetailData(result.rows[0]); + } else { + setAdditionalDetailData({}); + } + } catch (error) { + console.error("추가 데이터 로드 실패:", error); + setAdditionalDetailData({}); + } finally { + setDetailPopupLoading(false); + } + } + } + }, + [config.rowDetailPopup], + ); + + // 값 포맷팅 함수 + const formatValue = (value: any, format?: string): string => { + if (value === null || value === undefined) return "-"; + + switch (format) { + case "date": + return new Date(value).toLocaleDateString("ko-KR"); + case "datetime": + return new Date(value).toLocaleString("ko-KR"); + case "number": + return Number(value).toLocaleString("ko-KR"); + case "currency": + return `${Number(value).toLocaleString("ko-KR")}원`; + case "boolean": + return value ? "예" : "아니오"; + case "distance": + return typeof value === "number" ? `${value.toFixed(1)} km` : String(value); + case "duration": + return typeof value === "number" ? `${value}분` : String(value); + default: + return String(value); + } + }; + + // 아이콘 렌더링 + const renderIcon = (icon?: string, color?: string) => { + const colorClass = + color === "blue" + ? "text-blue-600" + : color === "orange" + ? "text-orange-600" + : color === "green" + ? "text-green-600" + : color === "red" + ? "text-red-600" + : color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + switch (icon) { + case "truck": + return ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // 필드 그룹 렌더링 + const renderFieldGroup = (group: FieldGroup, data: Record) => { + const colorClass = + group.color === "blue" + ? "text-blue-600" + : group.color === "orange" + ? "text-orange-600" + : group.color === "green" + ? "text-green-600" + : group.color === "red" + ? "text-red-600" + : group.color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + return ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(data[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // 기본 필드 그룹 생성 (설정이 없을 경우) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig 형식 지원 + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => item.column in row); + } else { + // 전체 컬럼 + basicFields = Object.keys(row).map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "기본 정보", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text", + })), + }); + + // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 + if (additional && Object.keys(additional).length > 0) { + // 운행 정보 + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "운행 정보", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "시작", format: "datetime" }, + { column: "last_trip_end", label: "종료", format: "datetime" }, + { column: "last_trip_distance", label: "거리", format: "distance" }, + { column: "last_trip_time", label: "시간", format: "duration" }, + { column: "departure", label: "출발지", format: "text" }, + { column: "arrival", label: "도착지", format: "text" }, + ], + }); + } + + // 공차 정보 + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "공차 정보", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "시작", format: "datetime" }, + { column: "last_empty_end", label: "종료", format: "datetime" }, + { column: "last_empty_distance", label: "거리", format: "distance" }, + { column: "last_empty_time", label: "시간", format: "duration" }, + ], + }); + } + } + + return groups; + }; + // 데이터 로드 useEffect(() => { const loadData = async () => { @@ -260,7 +484,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ) : ( paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns .filter((col) => col.visible) .map((col) => ( @@ -292,7 +520,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { }} > {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + >
{displayColumns .filter((col) => col.visible) @@ -345,6 +577,45 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
)} + + {/* 행 상세 팝업 */} + + + + {config.rowDetailPopup?.title || "상세 정보"} + + {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {detailPopupData && ( + <> + {/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */} + {config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0 + ? // 설정된 필드 그룹 렌더링 + config.rowDetailPopup.fieldGroups.map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + ) + : // 기본 필드 그룹 렌더링 + getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + )} + + )} +
+ )} + + + + + +
); } diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index c46244b1..802c9ef2 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -1,11 +1,19 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; +import { DashboardElement, ChartDataSource, FieldGroup } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card } from "@/components/ui/card"; -import { Loader2, RefreshCw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -34,6 +42,12 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); + // 행 상세 팝업 상태 + const [detailPopupOpen, setDetailPopupOpen] = useState(false); + const [detailPopupData, setDetailPopupData] = useState | null>(null); + const [detailPopupLoading, setDetailPopupLoading] = useState(false); + const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // // console.log("🧪 ListTestWidget 렌더링!", element); const dataSources = useMemo(() => { @@ -69,6 +83,216 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { cardColumns: 3, }; + // 행 클릭 핸들러 - 팝업 열기 + const handleRowClick = useCallback( + async (row: Record) => { + // 팝업이 비활성화되어 있으면 무시 + if (!config.rowDetailPopup?.enabled) return; + + setDetailPopupData(row); + setDetailPopupOpen(true); + setAdditionalDetailData(null); + setDetailPopupLoading(false); + + // 추가 데이터 조회 설정이 있으면 실행 + const additionalQuery = config.rowDetailPopup?.additionalQuery; + if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { + const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; + const matchValue = row[sourceColumn]; + + if (matchValue !== undefined && matchValue !== null) { + setDetailPopupLoading(true); + try { + const query = ` + SELECT * + FROM ${additionalQuery.tableName} + WHERE ${additionalQuery.matchColumn} = '${matchValue}' + LIMIT 1; + `; + + const { dashboardApi } = await import("@/lib/api/dashboard"); + const result = await dashboardApi.executeQuery(query); + + if (result.success && result.rows.length > 0) { + setAdditionalDetailData(result.rows[0]); + } else { + setAdditionalDetailData({}); + } + } catch (err) { + console.error("추가 데이터 로드 실패:", err); + setAdditionalDetailData({}); + } finally { + setDetailPopupLoading(false); + } + } + } + }, + [config.rowDetailPopup], + ); + + // 값 포맷팅 함수 + const formatValue = (value: any, format?: string): string => { + if (value === null || value === undefined) return "-"; + + switch (format) { + case "date": + return new Date(value).toLocaleDateString("ko-KR"); + case "datetime": + return new Date(value).toLocaleString("ko-KR"); + case "number": + return Number(value).toLocaleString("ko-KR"); + case "currency": + return `${Number(value).toLocaleString("ko-KR")}원`; + case "boolean": + return value ? "예" : "아니오"; + case "distance": + return typeof value === "number" ? `${value.toFixed(1)} km` : String(value); + case "duration": + return typeof value === "number" ? `${value}분` : String(value); + default: + return String(value); + } + }; + + // 아이콘 렌더링 + const renderIcon = (icon?: string, color?: string) => { + const colorClass = + color === "blue" + ? "text-blue-600" + : color === "orange" + ? "text-orange-600" + : color === "green" + ? "text-green-600" + : color === "red" + ? "text-red-600" + : color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + switch (icon) { + case "truck": + return ; + case "clock": + return ; + case "map": + return ; + case "package": + return ; + default: + return ; + } + }; + + // 필드 그룹 렌더링 + const renderFieldGroup = (group: FieldGroup, groupData: Record) => { + const colorClass = + group.color === "blue" + ? "text-blue-600" + : group.color === "orange" + ? "text-orange-600" + : group.color === "green" + ? "text-green-600" + : group.color === "red" + ? "text-red-600" + : group.color === "purple" + ? "text-purple-600" + : "text-gray-600"; + + return ( +
+
+ {renderIcon(group.icon, group.color)} + {group.title} +
+
+ {group.fields.map((field) => ( +
+ + {field.label} + + {formatValue(groupData[field.column], field.format)} +
+ ))} +
+
+ ); + }; + + // 기본 필드 그룹 생성 (설정이 없을 경우) + const getDefaultFieldGroups = (row: Record, additional: Record | null): FieldGroup[] => { + const groups: FieldGroup[] = []; + const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; + + // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 + const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외 + let basicFields: { column: string; label: string }[] = []; + + if (displayColumns && displayColumns.length > 0) { + // DisplayColumnConfig 형식 지원 + basicFields = displayColumns + .map((colConfig) => { + const column = typeof colConfig === 'object' ? colConfig.column : colConfig; + const label = typeof colConfig === 'object' ? colConfig.label : colConfig; + return { column, label }; + }) + .filter((item) => allKeys.includes(item.column)); + } else { + // 전체 컬럼 + basicFields = allKeys.map((key) => ({ column: key, label: key })); + } + + groups.push({ + id: "basic", + title: "기본 정보", + icon: "info", + color: "gray", + fields: basicFields.map((item) => ({ + column: item.column, + label: item.label, + format: "text" as const, + })), + }); + + // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 + if (additional && Object.keys(additional).length > 0) { + // 운행 정보 + if (additional.last_trip_start || additional.last_trip_end) { + groups.push({ + id: "trip", + title: "운행 정보", + icon: "truck", + color: "blue", + fields: [ + { column: "last_trip_start", label: "시작", format: "datetime" as const }, + { column: "last_trip_end", label: "종료", format: "datetime" as const }, + { column: "last_trip_distance", label: "거리", format: "distance" as const }, + { column: "last_trip_time", label: "시간", format: "duration" as const }, + { column: "departure", label: "출발지", format: "text" as const }, + { column: "arrival", label: "도착지", format: "text" as const }, + ], + }); + } + + // 공차 정보 + if (additional.last_empty_start) { + groups.push({ + id: "empty", + title: "공차 정보", + icon: "package", + color: "orange", + fields: [ + { column: "last_empty_start", label: "시작", format: "datetime" as const }, + { column: "last_empty_end", label: "종료", format: "datetime" as const }, + { column: "last_empty_distance", label: "거리", format: "distance" as const }, + { column: "last_empty_time", label: "시간", format: "duration" as const }, + ], + }); + } + } + + return groups; + }; + // visible 컬럼 설정 객체 배열 (field + label) const visibleColumnConfigs = useMemo(() => { if (config.columns && config.columns.length > 0 && typeof config.columns[0] === "object") { @@ -368,7 +592,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { )} {paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => ( {String(row[field] ?? "")} @@ -393,7 +621,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { return (
{paginatedRows.map((row, idx) => ( - + handleRowClick(row)} + > {displayColumns.map((field) => (
{getLabel(field)}: @@ -489,6 +721,45 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
)} + + {/* 행 상세 팝업 */} + + + + {config.rowDetailPopup?.title || "상세 정보"} + + {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + + + + {detailPopupLoading ? ( +
+
+
+ ) : ( +
+ {detailPopupData && ( + <> + {/* 설정된 필드 그룹이 있으면 사용, 없으면 기본 그룹 생성 */} + {config.rowDetailPopup?.fieldGroups && config.rowDetailPopup.fieldGroups.length > 0 + ? // 설정된 필드 그룹 렌더링 + config.rowDetailPopup.fieldGroups.map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + ) + : // 기본 필드 그룹 렌더링 + getDefaultFieldGroups(detailPopupData, additionalDetailData).map((group) => + renderFieldGroup(group, { ...detailPopupData, ...additionalDetailData }), + )} + + )} +
+ )} + + + + + +
); } From d1c9aeca18b7c3bf438b0a0d025fb184281bb095 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 10 Dec 2025 15:29:23 +0900 Subject: [PATCH 22/25] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=A1=B0=EA=B8=88=20=EB=8D=94=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=EB=90=9C=20=EB=B2=84=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/widgets/ListWidget.tsx | 6 +++++- frontend/components/dashboard/widgets/ListTestWidget.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index befb1286..2e69f72d 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -584,7 +584,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {config.rowDetailPopup?.title || "상세 정보"} - {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + {detailPopupLoading + ? "추가 정보를 로딩 중입니다..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "선택된 항목의 상세 정보입니다."} diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index 802c9ef2..24b9e320 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -728,7 +728,11 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { {config.rowDetailPopup?.title || "상세 정보"} - {detailPopupLoading ? "추가 정보를 로딩 중입니다..." : "선택된 항목의 상세 정보입니다."} + {detailPopupLoading + ? "추가 정보를 로딩 중입니다..." + : detailPopupData + ? `${Object.values(detailPopupData).filter(v => v && typeof v === 'string').slice(0, 2).join(' - ')}` + : "선택된 항목의 상세 정보입니다."} From e6b8212d3972b1ee1d7a001ad4474a302b109f19 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 10 Dec 2025 15:40:33 +0900 Subject: [PATCH 23/25] =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=20=EB=AA=A8=EB=8B=AC=EC=97=90=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/RestApiConnectionModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 0a9cecd0..4c60c5af 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -276,12 +276,12 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }: return ( - - + + {connection ? "REST API 연결 수정" : "새 REST API 연결 추가"} -
+
{/* 기본 정보 */}

기본 정보

@@ -588,7 +588,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
- +