diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 979d191b..bc7e5551 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -62,6 +62,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D import flowRoutes from "./routes/flowRoutes"; // 플로우 관리 import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 +import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -218,6 +219,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결 app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지) app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 +app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -245,12 +247,19 @@ app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); - // 대시보드 마이그레이션 실행 + // 데이터베이스 마이그레이션 실행 try { - const { runDashboardMigration } = await import("./database/runMigration"); + const { + runDashboardMigration, + runTableHistoryActionMigration, + runDtgManagementLogMigration, + } = await import("./database/runMigration"); + await runDashboardMigration(); + await runTableHistoryActionMigration(); + await runDtgManagementLogMigration(); } catch (error) { - logger.error(`❌ 대시보드 마이그레이션 실패:`, error); + logger.error(`❌ 마이그레이션 실패:`, error); } // 배치 스케줄러 초기화 @@ -279,17 +288,18 @@ app.listen(PORT, HOST, async () => { const { mailSentHistoryService } = await import( "./services/mailSentHistoryService" ); - + cron.schedule("0 2 * * *", async () => { try { logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작..."); - const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails(); + const deletedCount = + await mailSentHistoryService.cleanupOldDeletedMails(); logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`); } catch (error) { logger.error("❌ 메일 자동 삭제 실패:", error); } }); - + logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`); } catch (error) { logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error); diff --git a/backend-node/src/controllers/tableHistoryController.ts b/backend-node/src/controllers/tableHistoryController.ts new file mode 100644 index 00000000..a32f31ad --- /dev/null +++ b/backend-node/src/controllers/tableHistoryController.ts @@ -0,0 +1,406 @@ +/** + * 테이블 이력 조회 컨트롤러 + * 테이블 타입 관리의 {테이블명}_log 테이블과 연동 + */ + +import { Request, Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +export class TableHistoryController { + /** + * 특정 레코드의 변경 이력 조회 + */ + static async getRecordHistory(req: Request, res: Response): Promise { + try { + const { tableName, recordId } = req.params; + const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; + + logger.info(`📜 테이블 이력 조회 요청:`, { + tableName, + recordId, + limit, + offset, + }); + + // 로그 테이블명 생성 + const logTableName = `${tableName}_log`; + + // 동적 WHERE 조건 생성 + const whereConditions: string[] = [`original_id = $1`]; + const queryParams: any[] = [recordId]; + let paramIndex = 2; + + // 작업 유형 필터 + if (operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + queryParams.push(operationType); + paramIndex++; + } + + // 변경자 필터 + if (changedBy) { + whereConditions.push(`changed_by ILIKE $${paramIndex}`); + queryParams.push(`%${changedBy}%`); + paramIndex++; + } + + // 날짜 범위 필터 + if (startDate) { + whereConditions.push(`changed_at >= $${paramIndex}`); + queryParams.push(startDate); + paramIndex++; + } + if (endDate) { + whereConditions.push(`changed_at <= $${paramIndex}`); + queryParams.push(endDate); + paramIndex++; + } + + // LIMIT과 OFFSET 파라미터 추가 + queryParams.push(limit); + const limitParam = `$${paramIndex}`; + paramIndex++; + + queryParams.push(offset); + const offsetParam = `$${paramIndex}`; + + const whereClause = whereConditions.join(" AND "); + + // 이력 조회 쿼리 + const historyQuery = ` + SELECT + log_id, + operation_type, + original_id, + changed_column, + old_value, + new_value, + changed_by, + changed_at, + ip_address, + user_agent, + full_row_before, + full_row_after + FROM ${logTableName} + WHERE ${whereClause} + ORDER BY changed_at DESC + LIMIT ${limitParam} OFFSET ${offsetParam} + `; + + // 전체 카운트 쿼리 + const countQuery = ` + SELECT COUNT(*) as total + FROM ${logTableName} + WHERE ${whereClause} + `; + + const [historyRecords, countResult] = await Promise.all([ + query(historyQuery, queryParams), + query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 + ]); + + const total = parseInt(countResult[0]?.total || "0", 10); + + logger.info(`✅ 이력 조회 완료: ${historyRecords.length}건 / 전체 ${total}건`); + + res.json({ + success: true, + data: { + records: historyRecords, + pagination: { + total, + limit: parseInt(limit as string, 10), + offset: parseInt(offset as string, 10), + hasMore: parseInt(offset as string, 10) + historyRecords.length < total, + }, + }, + message: "이력 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 테이블 이력 조회 실패:`, error); + + // 테이블이 존재하지 않는 경우 + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다. 테이블 타입 관리에서 이력 관리를 활성화해주세요.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 전체 테이블 이력 조회 (레코드 ID 없이) + */ + static async getAllTableHistory(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; + + logger.info(`📜 전체 테이블 이력 조회 요청:`, { + tableName, + limit, + offset, + }); + + // 로그 테이블명 생성 + const logTableName = `${tableName}_log`; + + // 동적 WHERE 조건 생성 + const whereConditions: string[] = []; + const queryParams: any[] = []; + let paramIndex = 1; + + // 작업 유형 필터 + if (operationType) { + whereConditions.push(`operation_type = $${paramIndex}`); + queryParams.push(operationType); + paramIndex++; + } + + // 변경자 필터 + if (changedBy) { + whereConditions.push(`changed_by ILIKE $${paramIndex}`); + queryParams.push(`%${changedBy}%`); + paramIndex++; + } + + // 날짜 범위 필터 + if (startDate) { + whereConditions.push(`changed_at >= $${paramIndex}`); + queryParams.push(startDate); + paramIndex++; + } + if (endDate) { + whereConditions.push(`changed_at <= $${paramIndex}`); + queryParams.push(endDate); + paramIndex++; + } + + // LIMIT과 OFFSET 파라미터 추가 + queryParams.push(limit); + const limitParam = `$${paramIndex}`; + paramIndex++; + + queryParams.push(offset); + const offsetParam = `$${paramIndex}`; + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + // 이력 조회 쿼리 + const historyQuery = ` + SELECT + log_id, + operation_type, + original_id, + changed_column, + old_value, + new_value, + changed_by, + changed_at, + ip_address, + user_agent, + full_row_before, + full_row_after + FROM ${logTableName} + ${whereClause} + ORDER BY changed_at DESC + LIMIT ${limitParam} OFFSET ${offsetParam} + `; + + // 전체 카운트 쿼리 + const countQuery = ` + SELECT COUNT(*) as total + FROM ${logTableName} + ${whereClause} + `; + + const [historyRecords, countResult] = await Promise.all([ + query(historyQuery, queryParams), + query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 + ]); + + const total = parseInt(countResult[0]?.total || "0", 10); + + res.json({ + success: true, + data: { + records: historyRecords, + pagination: { + total, + limit: Number(limit), + offset: Number(offset), + hasMore: Number(offset) + Number(limit) < total, + }, + }, + message: "전체 테이블 이력 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 전체 테이블 이력 조회 실패:`, error); + + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 테이블 전체 이력 요약 조회 + */ + static async getTableHistorySummary(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const logTableName = `${tableName}_log`; + + const summaryQuery = ` + SELECT + operation_type, + COUNT(*) as count, + COUNT(DISTINCT original_id) as affected_records, + COUNT(DISTINCT changed_by) as unique_users, + MIN(changed_at) as first_change, + MAX(changed_at) as last_change + FROM ${logTableName} + GROUP BY operation_type + `; + + const summary = await query(summaryQuery); + + res.json({ + success: true, + data: summary, + message: "이력 요약 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 테이블 이력 요약 조회 실패:`, error); + + if (error.code === "42P01") { + res.status(404).json({ + success: false, + message: "이력 테이블이 존재하지 않습니다.", + errorCode: "TABLE_NOT_FOUND", + }); + return; + } + + res.status(500).json({ + success: false, + message: "이력 요약 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 특정 레코드의 변경 타임라인 조회 (그룹화) + */ + static async getRecordTimeline(req: Request, res: Response): Promise { + try { + const { tableName, recordId } = req.params; + const logTableName = `${tableName}_log`; + + // 변경 이벤트별로 그룹화 (동일 시간대 변경을 하나의 이벤트로) + const timelineQuery = ` + WITH grouped_changes AS ( + SELECT + changed_at, + changed_by, + operation_type, + ip_address, + json_agg( + json_build_object( + 'column', changed_column, + 'oldValue', old_value, + 'newValue', new_value + ) ORDER BY changed_column + ) as changes, + full_row_before, + full_row_after + FROM ${logTableName} + WHERE original_id = $1 + GROUP BY changed_at, changed_by, operation_type, ip_address, full_row_before, full_row_after + ORDER BY changed_at DESC + LIMIT 100 + ) + SELECT * FROM grouped_changes + `; + + const timeline = await query(timelineQuery, [recordId]); + + res.json({ + success: true, + data: timeline, + message: "타임라인 조회 성공", + }); + } catch (error: any) { + logger.error(`❌ 레코드 타임라인 조회 실패:`, error); + + res.status(500).json({ + success: false, + message: "타임라인 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + /** + * 이력 테이블 존재 여부 확인 + */ + static async checkHistoryTableExists(req: Request, res: Response): Promise { + try { + const { tableName } = req.params; + const logTableName = `${tableName}_log`; + + const checkQuery = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ) as exists + `; + + const result = await query(checkQuery, [logTableName]); + const exists = result[0]?.exists || false; + + res.json({ + success: true, + data: { + tableName, + logTableName, + exists, + historyEnabled: exists, + }, + message: exists ? "이력 테이블이 존재합니다." : "이력 테이블이 존재하지 않습니다.", + }); + } catch (error: any) { + logger.error(`❌ 이력 테이블 존재 여부 확인 실패:`, error); + + res.status(500).json({ + success: false, + message: "이력 테이블 확인 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +} + diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 61b98241..a1bb2ec5 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -1,4 +1,6 @@ -import { PostgreSQLService } from './PostgreSQLService'; +import { PostgreSQLService } from "./PostgreSQLService"; +import fs from "fs"; +import path from "path"; /** * 데이터베이스 마이그레이션 실행 @@ -6,21 +8,21 @@ import { PostgreSQLService } from './PostgreSQLService'; */ export async function runDashboardMigration() { try { - console.log('🔄 대시보드 마이그레이션 시작...'); + console.log("🔄 대시보드 마이그레이션 시작..."); // custom_title 컬럼 추가 await PostgreSQLService.query(` ALTER TABLE dashboard_elements ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255) `); - console.log('✅ custom_title 컬럼 추가 완료'); + console.log("✅ custom_title 컬럼 추가 완료"); // show_header 컬럼 추가 await PostgreSQLService.query(` ALTER TABLE dashboard_elements ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true `); - console.log('✅ show_header 컬럼 추가 완료'); + console.log("✅ show_header 컬럼 추가 완료"); // 기존 데이터 업데이트 await PostgreSQLService.query(` @@ -28,15 +30,83 @@ export async function runDashboardMigration() { SET show_header = true WHERE show_header IS NULL `); - console.log('✅ 기존 데이터 업데이트 완료'); + console.log("✅ 기존 데이터 업데이트 완료"); - console.log('✅ 대시보드 마이그레이션 완료!'); + console.log("✅ 대시보드 마이그레이션 완료!"); } catch (error) { - console.error('❌ 대시보드 마이그레이션 실패:', error); + console.error("❌ 대시보드 마이그레이션 실패:", error); // 이미 컬럼이 있는 경우는 무시 - if (error instanceof Error && error.message.includes('already exists')) { - console.log('ℹ️ 컬럼이 이미 존재합니다.'); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 컬럼이 이미 존재합니다."); } } } +/** + * 테이블 이력 보기 버튼 액션 마이그레이션 + */ +export async function runTableHistoryActionMigration() { + try { + console.log("🔄 테이블 이력 보기 액션 마이그레이션 시작..."); + + // SQL 파일 읽기 + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/024_add_table_history_view_action.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + + // SQL 실행 + await PostgreSQLService.query(sqlContent); + + console.log("✅ 테이블 이력 보기 액션 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 테이블 이력 보기 액션 마이그레이션 실패:", error); + // 이미 액션이 있는 경우는 무시 + if ( + error instanceof Error && + error.message.includes("duplicate key value") + ) { + console.log("ℹ️ 액션이 이미 존재합니다."); + } + } +} + +/** + * DTG Management 테이블 이력 시스템 마이그레이션 + */ +export async function runDtgManagementLogMigration() { + try { + console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); + + // SQL 파일 읽기 + const sqlFilePath = path.join( + __dirname, + "../../db/migrations/025_create_dtg_management_log.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + + // SQL 실행 + await PostgreSQLService.query(sqlContent); + + console.log("✅ DTG Management 이력 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ DTG Management 이력 테이블 마이그레이션 실패:", error); + // 이미 테이블이 있는 경우는 무시 + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 이력 테이블이 이미 존재합니다."); + } + } +} diff --git a/backend-node/src/routes/tableHistoryRoutes.ts b/backend-node/src/routes/tableHistoryRoutes.ts new file mode 100644 index 00000000..c218ba37 --- /dev/null +++ b/backend-node/src/routes/tableHistoryRoutes.ts @@ -0,0 +1,35 @@ +/** + * 테이블 이력 조회 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { TableHistoryController } from "../controllers/tableHistoryController"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 이력 테이블 존재 여부 확인 +router.get("/:tableName/check", TableHistoryController.checkHistoryTableExists); + +// 테이블 전체 이력 요약 +router.get( + "/:tableName/summary", + TableHistoryController.getTableHistorySummary +); + +// 전체 테이블 이력 조회 (레코드 ID 없이) +router.get("/:tableName/all", TableHistoryController.getAllTableHistory); + +// 특정 레코드의 타임라인 +router.get( + "/:tableName/:recordId/timeline", + TableHistoryController.getRecordTimeline +); + +// 특정 레코드의 변경 이력 (상세) +router.get("/:tableName/:recordId", TableHistoryController.getRecordHistory); + +export default router; diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index c8caa2df..a85a033f 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -60,7 +60,7 @@ export default function TableManagementPage() { // 페이지네이션 상태 const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(50); + const [pageSize, setPageSize] = useState(9999); // 전체 컬럼 표시 const [totalColumns, setTotalColumns] = useState(0); // 테이블 라벨 상태 diff --git a/frontend/components/common/TableHistoryModal.tsx b/frontend/components/common/TableHistoryModal.tsx new file mode 100644 index 00000000..1f0dee4d --- /dev/null +++ b/frontend/components/common/TableHistoryModal.tsx @@ -0,0 +1,396 @@ +"use client"; + +/** + * 테이블 이력 뷰어 모달 + * 테이블 타입 관리의 {테이블명}_log 테이블 데이터를 표시 + */ + +import React, { useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Clock, User, FileEdit, Trash2, Plus, AlertCircle, Loader2, Search, X } from "lucide-react"; +import { + getRecordHistory, + getRecordTimeline, + TableHistoryRecord, + TableHistoryTimelineEvent, +} from "@/lib/api/tableHistory"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +interface TableHistoryModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + tableName: string; + recordId?: string | number | null; // 선택사항: null이면 전체 테이블 이력 + recordLabel?: string; + displayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) +} + +export function TableHistoryModal({ + open, + onOpenChange, + tableName, + recordId, + recordLabel, + displayColumn, +}: TableHistoryModalProps) { + const [loading, setLoading] = useState(false); + const [timeline, setTimeline] = useState([]); + const [detailRecords, setDetailRecords] = useState([]); + const [allRecords, setAllRecords] = useState([]); // 검색용 원본 데이터 + // recordId가 없으면 (전체 테이블 모드) detail 탭부터 시작 + const [activeTab, setActiveTab] = useState<"timeline" | "detail">(recordId ? "timeline" : "detail"); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); // 검색어 + + useEffect(() => { + if (open) { + loadHistory(); + // recordId 변경 시 탭도 초기화 + setActiveTab(recordId ? "timeline" : "detail"); + } + }, [open, tableName, recordId]); + + const loadHistory = async () => { + setLoading(true); + setError(null); + + try { + if (recordId) { + // 단일 레코드 이력 로드 + const [timelineRes, detailRes] = await Promise.all([ + getRecordTimeline(tableName, recordId), + getRecordHistory(tableName, recordId, { limit: 100 }), + ]); + + if (timelineRes.success && timelineRes.data) { + setTimeline(timelineRes.data); + } else { + setError(timelineRes.error || "타임라인 로드 실패"); + } + + if (detailRes.success && detailRes.data) { + setDetailRecords(detailRes.data.records); + setAllRecords(detailRes.data.records); + } + } else { + // 전체 테이블 이력 로드 (recordId 없이) + const detailRes = await getRecordHistory(tableName, null, { limit: 200 }); + + if (detailRes.success && detailRes.data) { + const records = detailRes.data.records; + setAllRecords(records); // 원본 데이터 저장 + setDetailRecords(records); // 초기 표시 데이터 + // 타임라인은 전체 테이블에서는 사용하지 않음 + setTimeline([]); + } else { + setError(detailRes.error || "이력 로드 실패"); + } + } + } catch (err: any) { + setError(err.message || "이력 로드 중 오류 발생"); + } finally { + setLoading(false); + } + }; + + const getOperationIcon = (type: string) => { + switch (type) { + case "INSERT": + return ; + case "UPDATE": + return ; + case "DELETE": + return ; + default: + return ; + } + }; + + const getOperationBadge = (type: string) => { + switch (type) { + case "INSERT": + return 추가; + case "UPDATE": + return 수정; + case "DELETE": + return 삭제; + default: + return ( + + {type} + + ); + } + }; + + const formatDate = (dateString: string) => { + try { + return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko }); + } catch { + return dateString; + } + }; + + // 검색 필터링 (전체 테이블 모드에서만) + const handleSearch = (term: string) => { + setSearchTerm(term); + + if (!term.trim()) { + // 검색어가 없으면 전체 표시 + setDetailRecords(allRecords); + return; + } + + const lowerTerm = term.toLowerCase(); + const filtered = allRecords.filter((record) => { + // 레코드 ID로 검색 + if (record.original_id?.toString().toLowerCase().includes(lowerTerm)) { + return true; + } + + // displayColumn 값으로 검색 (full_row_after에서 추출) + if (displayColumn && record.full_row_after) { + const displayValue = record.full_row_after[displayColumn]; + if (displayValue && displayValue.toString().toLowerCase().includes(lowerTerm)) { + return true; + } + } + + // 변경자로 검색 + if (record.changed_by?.toLowerCase().includes(lowerTerm)) { + return true; + } + + // 컬럼명으로 검색 + if (record.changed_column?.toLowerCase().includes(lowerTerm)) { + return true; + } + + return false; + }); + + setDetailRecords(filtered); + }; + + // displayColumn 값 추출 헬퍼 함수 + const getDisplayValue = (record: TableHistoryRecord): string | null => { + if (!displayColumn) return null; + + // full_row_after에서 먼저 시도 + if (record.full_row_after && record.full_row_after[displayColumn]) { + return record.full_row_after[displayColumn]; + } + + // full_row_before에서 시도 (DELETE의 경우) + if (record.full_row_before && record.full_row_before[displayColumn]) { + return record.full_row_before[displayColumn]; + } + + return null; + }; + + return ( + + + + + + 변경 이력{" "} + {!recordId && ( + + 전체 + + )} + + + {recordId + ? `${recordLabel || `레코드 ID: ${recordId}`} - ${tableName} 테이블` + : `${tableName} 테이블 전체 이력`} + + + + {loading ? ( +
+ + 로딩 중... +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : ( + setActiveTab(v as any)} className="w-full"> + {recordId && ( + + + 타임라인 ({timeline.length}) + + + 상세 내역 ({detailRecords.length}) + + + )} + {!recordId && ( + <> + + + 전체 변경 이력 ({detailRecords.length}) + + + + {/* 검색창 (전체 테이블 모드) */} +
+ + handleSearch(e.target.value)} + className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm" + /> + {searchTerm && ( + + )} +
+ + {searchTerm && ( +

+ 검색 결과: {detailRecords.length}개 / 전체 {allRecords.length}개 +

+ )} + + )} + + {/* 타임라인 뷰 */} + + + {timeline.length === 0 ? ( +
+ +

변경 이력이 없습니다

+
+ ) : ( +
+ {timeline.map((event, index) => ( +
+
+ {getOperationIcon(event.operation_type)} +
+ +
+
+ {getOperationBadge(event.operation_type)} + {formatDate(event.changed_at)} +
+ +
+ + {event.changed_by} + {event.ip_address && ( + ({event.ip_address}) + )} +
+ + {event.changes && event.changes.length > 0 && ( +
+

변경된 항목:

+
+ {event.changes.map((change, idx) => ( +
+ {change.column} +
+ {change.oldValue || "(없음)"} + + {change.newValue || "(없음)"} +
+
+ ))} +
+
+ )} +
+
+ ))} +
+ )} +
+
+ + {/* 상세 내역 뷰 */} + + + {detailRecords.length === 0 ? ( +
+ +

변경 내역이 없습니다

+
+ ) : ( + + + + {!recordId && } + + + + + + + + + + {detailRecords.map((record) => { + const displayValue = getDisplayValue(record); + return ( + + {!recordId && ( + + )} + + + + + + + + ); + })} + +
레코드작업컬럼이전 값새 값변경자일시
+ {displayValue ? ( +
+ {displayValue} + (ID: {record.original_id}) +
+ ) : ( + {record.original_id} + )} +
{getOperationBadge(record.operation_type)}{record.changed_column}{record.old_value || "-"}{record.new_value || "-"}{record.changed_by}{formatDate(record.changed_at)}
+ )} +
+
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5cf9f4c3..35e82c7c 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -19,6 +20,7 @@ interface ButtonConfigPanelProps { component: ComponentData; onUpdateProperty: (path: string, value: any) => void; allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용 + currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) } interface ScreenOption { @@ -27,20 +29,23 @@ interface ScreenOption { description?: string; } -export const ButtonConfigPanel: React.FC = ({ - component, +export const ButtonConfigPanel: React.FC = ({ + component, onUpdateProperty, allComponents = [], // 🆕 기본값 빈 배열 + currentTableName, // 현재 화면의 테이블명 }) => { - console.log("🎨 ButtonConfigPanel 렌더링:", { - componentId: component.id, - "component.componentConfig?.action?.type": component.componentConfig?.action?.type, - }); - // 🔧 component에서 직접 읽기 (useMemo 제거) const config = component.componentConfig || {}; const currentAction = component.componentConfig?.action || {}; + console.log("🎨 ButtonConfigPanel 렌더링:", { + componentId: component.id, + "component.componentConfig?.action?.type": component.componentConfig?.action?.type, + currentTableName: currentTableName, + "config.action?.historyTableName": config.action?.historyTableName, + }); + // 로컬 상태 관리 (실시간 입력 반영) const [localInputs, setLocalInputs] = useState({ text: config.text !== undefined ? config.text : "버튼", @@ -57,6 +62,12 @@ export const ButtonConfigPanel: React.FC = ({ const [modalSearchTerm, setModalSearchTerm] = useState(""); const [navSearchTerm, setNavSearchTerm] = useState(""); + // 테이블 컬럼 목록 상태 + const [tableColumns, setTableColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + const [displayColumnOpen, setDisplayColumnOpen] = useState(false); + const [displayColumnSearch, setDisplayColumnSearch] = useState(""); + // 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만) useEffect(() => { const latestConfig = component.componentConfig || {}; @@ -103,6 +114,115 @@ export const ButtonConfigPanel: React.FC = ({ fetchScreens(); }, []); + // 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때) + useEffect(() => { + const fetchTableColumns = async () => { + // 테이블 이력 보기 액션이 아니면 스킵 + if (config.action?.type !== "view_table_history") { + return; + } + + // 1. 수동 입력된 테이블명 우선 + // 2. 없으면 현재 화면의 테이블명 사용 + const tableName = config.action?.historyTableName || currentTableName; + + // 테이블명이 없으면 스킵 + if (!tableName) { + return; + } + + try { + setColumnsLoading(true); + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, { + params: { + page: 1, + size: 9999, // 전체 컬럼 가져오기 + }, + }); + + console.log("📋 [ButtonConfigPanel] API 응답:", { + tableName, + success: response.data.success, + hasData: !!response.data.data, + hasColumns: !!response.data.data?.columns, + totalColumns: response.data.data?.columns?.length, + }); + + // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } + const columnData = response.data.data?.columns; + + if (!columnData || !Array.isArray(columnData)) { + console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData); + setTableColumns([]); + return; + } + + if (response.data.success) { + // ID 컬럼과 날짜 관련 컬럼 제외 + const filteredColumns = columnData + .filter((col: any) => { + const colName = col.columnName.toLowerCase(); + const dataType = col.dataType?.toLowerCase() || ""; + + console.log(`🔍 [필터링 체크] ${col.columnName}:`, { + colName, + dataType, + isId: colName === "id" || colName.endsWith("_id"), + hasDateInType: dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp"), + hasDateInName: + colName.includes("date") || + colName.includes("time") || + colName.endsWith("_at") || + colName.startsWith("created") || + colName.startsWith("updated"), + }); + + // ID 컬럼 제외 (id, _id로 끝나는 컬럼) + if (colName === "id" || colName.endsWith("_id")) { + console.log(` ❌ 제외: ID 컬럼`); + return false; + } + + // 날짜/시간 타입 제외 (데이터 타입 기준) + if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) { + console.log(` ❌ 제외: 날짜/시간 타입`); + return false; + } + + // 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함) + if ( + colName.includes("date") || + colName.includes("time") || + colName.endsWith("_at") || + colName.startsWith("created") || + colName.startsWith("updated") + ) { + console.log(` ❌ 제외: 날짜 관련 컬럼명`); + return false; + } + + console.log(` ✅ 통과`); + return true; + }) + .map((col: any) => col.columnName); + + console.log("✅ [ButtonConfigPanel] 필터링된 컬럼:", { + totalFiltered: filteredColumns.length, + columns: filteredColumns, + }); + + setTableColumns(filteredColumns); + } + } catch (error) { + console.error("❌ 테이블 컬럼 로딩 실패:", error); + } finally { + setColumnsLoading(false); + } + }; + + fetchTableColumns(); + }, [config.action?.type, config.action?.historyTableName, currentTableName]); + // 검색 필터링 함수 const filterScreens = (searchTerm: string) => { if (!searchTerm.trim()) return screens; @@ -185,20 +305,20 @@ export const ButtonConfigPanel: React.FC = ({ key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`} value={component.componentConfig?.action?.type || "save"} onValueChange={(value) => { - console.log("🎯 버튼 액션 드롭다운 변경:", { - oldValue: component.componentConfig?.action?.type, - newValue: value, - }); - - // 🔥 action.type 업데이트 - onUpdateProperty("componentConfig.action.type", value); - - // 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후) - setTimeout(() => { - const newColor = value === "delete" ? "#ef4444" : "#212121"; - console.log("🎨 라벨 색상 업데이트:", { value, newColor }); - onUpdateProperty("style.labelColor", newColor); - }, 100); // 0 → 100ms로 증가 + console.log("🎯 버튼 액션 드롭다운 변경:", { + oldValue: component.componentConfig?.action?.type, + newValue: value, + }); + + // 🔥 action.type 업데이트 + onUpdateProperty("componentConfig.action.type", value); + + // 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후) + setTimeout(() => { + const newColor = value === "delete" ? "#ef4444" : "#212121"; + console.log("🎨 라벨 색상 업데이트:", { value, newColor }); + onUpdateProperty("style.labelColor", newColor); + }, 100); // 0 → 100ms로 증가 }} > @@ -211,6 +331,7 @@ export const ButtonConfigPanel: React.FC = ({ 페이지 이동 모달 열기 제어 흐름 + 테이블 이력 보기 @@ -476,6 +597,162 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 테이블 이력 보기 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "view_table_history" && ( +
+

📜 테이블 이력 보기 설정

+ +
+ + { + onUpdateProperty("componentConfig.action.historyTableName", e.target.value); + }} + /> +

비워두면 현재 화면의 테이블을 자동으로 사용합니다

+
+ +
+ + { + onUpdateProperty("componentConfig.action.historyRecordIdField", e.target.value); + }} + /> +

기본키 컬럼명입니다. 대부분 "id"입니다.

+
+ +
+ + +

테이블 리스트에서 선택된 행의 ID를 사용합니다

+
+ +
+ + { + onUpdateProperty("componentConfig.action.historyRecordLabelField", e.target.value); + }} + /> +

+ 이력 모달에서 "ID 123의 이력" 대신 "홍길동의 이력" 처럼 표시할 때 사용 +

+
+ +
+ + + {!config.action?.historyTableName && !currentTableName ? ( +
+

+ ⚠️ 먼저 테이블명을 입력하거나, 현재 화면에 테이블을 연결해주세요. +

+
+ ) : ( + <> + {!config.action?.historyTableName && currentTableName && ( +
+

+ ✓ 현재 화면의 테이블 {currentTableName}을(를) 자동으로 사용합니다. +

+
+ )} + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {tableColumns.map((column) => ( + { + onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue); + setDisplayColumnOpen(false); + }} + className="text-sm" + > + + {column} + + ))} + + + + + + +

+ 전체 테이블 이력에서 레코드를 구분하기 위한 컬럼입니다. +
+ 예: device_code를 설정하면 "레코드 ID: 5" + 대신 "DTG-001 (ID: 5)"로 표시됩니다. +
이 컬럼으로 검색도 가능합니다. +

+ + {tableColumns.length === 0 && !columnsLoading && ( +

+ ⚠️ ID 및 날짜 타입 컬럼을 제외한 사용 가능한 컬럼이 없습니다. +

+ )} + + )} +
+
+ )} + {/* 페이지 이동 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "navigate" && (
@@ -580,13 +857,12 @@ export const ButtonConfigPanel: React.FC = ({ {/* 🆕 플로우 단계별 표시 제어 섹션 */}
-
); }; - diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 9540c21b..1320778e 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -50,13 +50,13 @@ export const DetailSettingsPanel: React.FC = ({ // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 const { webTypes } = useWebTypes({ active: "Y" }); - // console.log(`🔍 DetailSettingsPanel props:`, { - // selectedComponent: selectedComponent?.id, - // componentType: selectedComponent?.type, - // currentTableName, - // currentTable: currentTable?.tableName, - // selectedComponentTableName: selectedComponent?.tableName, - // }); + console.log(`🔍 DetailSettingsPanel props:`, { + selectedComponent: selectedComponent?.id, + componentType: selectedComponent?.type, + currentTableName, + currentTable: currentTable?.tableName, + selectedComponentTableName: selectedComponent?.tableName, + }); // console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개"); // console.log(`🔍 webTypes:`, webTypes); // console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent); @@ -823,7 +823,14 @@ export const DetailSettingsPanel: React.FC = ({ case "button-primary": case "button-secondary": // 🔧 component.id만 key로 사용 (unmount 방지) - return ; + return ( + + ); case "card": return ; diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 237a94a8..bb6ccf66 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -108,16 +108,13 @@ export const UnifiedPropertiesPanel: React.FC = ({ {currentResolution && onResolutionChange && (
- +

해상도 설정

- +
)} - + {/* 안내 메시지 */}
@@ -156,7 +153,15 @@ export const UnifiedPropertiesPanel: React.FC = ({ case "button-primary": case "button-secondary": // 🔧 component.id만 key로 사용 (unmount 방지) - return ; + return ( + + ); case "card": return ; @@ -198,12 +203,12 @@ export const UnifiedPropertiesPanel: React.FC = ({ return (
{/* 컴포넌트 정보 - 간소화 */} -
+
- - {selectedComponent.type} + + {selectedComponent.type}
- {selectedComponent.id.slice(0, 8)} + {selectedComponent.id.slice(0, 8)}
{/* 라벨 + 최소 높이 (같은 행) */} @@ -609,7 +614,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 헤더 - 간소화 */}
{selectedComponent.type === "widget" && ( -
+
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
)} @@ -623,13 +628,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ <>
- +

해상도 설정

- +
@@ -637,7 +639,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 기본 설정 */} {renderBasicTab()} - + {/* 상세 설정 */} {renderDetailTab()} @@ -648,7 +650,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
- +

컴포넌트 스타일

| null; + full_row_after: Record | null; +} + +export interface TableHistoryResponse { + success: boolean; + data?: { + records: TableHistoryRecord[]; + pagination: { + total: number; + limit: number; + offset: number; + hasMore: boolean; + }; + }; + message?: string; + error?: string; + errorCode?: string; +} + +export interface TableHistoryTimelineEvent { + changed_at: string; + changed_by: string; + operation_type: "INSERT" | "UPDATE" | "DELETE"; + ip_address: string | null; + changes: Array<{ + column: string; + oldValue: string | null; + newValue: string | null; + }>; + full_row_before: Record | null; + full_row_after: Record | null; +} + +export interface TableHistoryTimelineResponse { + success: boolean; + data?: TableHistoryTimelineEvent[]; + message?: string; + error?: string; +} + +export interface TableHistorySummary { + operation_type: string; + count: number; + affected_records: number; + unique_users: number; + first_change: string; + last_change: string; +} + +export interface TableHistorySummaryResponse { + success: boolean; + data?: TableHistorySummary[]; + message?: string; + error?: string; +} + +export interface TableHistoryCheckResponse { + success: boolean; + data?: { + tableName: string; + logTableName: string; + exists: boolean; + historyEnabled: boolean; + }; + message?: string; + error?: string; +} + +/** + * 레코드 변경 이력 조회 (recordId가 null이면 전체 테이블 이력) + */ +export async function getRecordHistory( + tableName: string, + recordId: string | number | null, + params?: { + limit?: number; + offset?: number; + operationType?: "INSERT" | "UPDATE" | "DELETE"; + changedBy?: string; + startDate?: string; + endDate?: string; + }, +): Promise { + try { + const queryParams = new URLSearchParams(); + if (params?.limit) queryParams.append("limit", params.limit.toString()); + if (params?.offset) queryParams.append("offset", params.offset.toString()); + if (params?.operationType) queryParams.append("operationType", params.operationType); + if (params?.changedBy) queryParams.append("changedBy", params.changedBy); + if (params?.startDate) queryParams.append("startDate", params.startDate); + if (params?.endDate) queryParams.append("endDate", params.endDate); + + // recordId가 null이면 전체 테이블 이력 조회 + const url = recordId + ? `/table-history/${tableName}/${recordId}?${queryParams.toString()}` + : `/table-history/${tableName}/all?${queryParams.toString()}`; + + const response = await apiClient.get(url); + return response.data; + } catch (error: any) { + console.error("❌ 레코드 이력 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 조회 중 오류가 발생했습니다.", + errorCode: error.response?.data?.errorCode, + }; + } +} + +/** + * 특정 레코드의 타임라인 조회 (그룹화된 이벤트) + */ +export async function getRecordTimeline( + tableName: string, + recordId: string | number, +): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/${recordId}/timeline`); + return response.data; + } catch (error: any) { + console.error("❌ 타임라인 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "타임라인 조회 중 오류가 발생했습니다.", + }; + } +} + +/** + * 테이블 전체 이력 요약 + */ +export async function getTableHistorySummary(tableName: string): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/summary`); + return response.data; + } catch (error: any) { + console.error("❌ 이력 요약 조회 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 요약 조회 중 오류가 발생했습니다.", + }; + } +} + +/** + * 이력 테이블 존재 여부 확인 + */ +export async function checkHistoryTableExists(tableName: string): Promise { + try { + const response = await apiClient.get(`/table-history/${tableName}/check`); + return response.data; + } catch (error: any) { + console.error("❌ 이력 테이블 확인 실패:", error); + return { + success: false, + error: error.response?.data?.message || error.message || "이력 테이블 확인 중 오류가 발생했습니다.", + }; + } +} diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index d483f9af..00819387 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -38,7 +38,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - + // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -187,12 +187,6 @@ export const ButtonPrimaryComponent: React.FC = ({ const buttonDarkColor = getDarkColor(buttonColor); - console.log("🎨 동적 색상 연동:", { - labelColor: component.style?.labelColor, - buttonColor, - buttonDarkColor, - }); - // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 const processedConfig = { ...componentConfig }; if (componentConfig.action && typeof componentConfig.action === "string") { @@ -213,7 +207,6 @@ export const ButtonPrimaryComponent: React.FC = ({ }; } - // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 const componentStyle: React.CSSProperties = { @@ -223,7 +216,6 @@ export const ButtonPrimaryComponent: React.FC = ({ ...style, }; - // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) if (isDesignMode) { componentStyle.borderWidth = "1px"; @@ -279,7 +271,7 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } // 기본 에러 메시지 결정 - const defaultErrorMessage = + const defaultErrorMessage = actionConfig.type === "save" ? "저장 중 오류가 발생했습니다." : actionConfig.type === "delete" @@ -287,16 +279,15 @@ export const ButtonPrimaryComponent: React.FC = ({ : actionConfig.type === "submit" ? "제출 중 오류가 발생했습니다." : "처리 중 오류가 발생했습니다."; - + // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) - const useCustomMessage = - actionConfig.errorMessage && - (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); - + const useCustomMessage = + actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장")); + const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage; - + toast.error(errorMessage); return; } @@ -305,7 +296,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요) if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") { // 기본 성공 메시지 결정 - const defaultSuccessMessage = + const defaultSuccessMessage = actionConfig.type === "save" ? "저장되었습니다." : actionConfig.type === "delete" @@ -313,14 +304,14 @@ export const ButtonPrimaryComponent: React.FC = ({ : actionConfig.type === "submit" ? "제출되었습니다." : "완료되었습니다."; - + // 커스텀 메시지 사용 조건: // 1. 커스텀 메시지가 있고 // 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우) - const useCustomMessage = + const useCustomMessage = actionConfig.successMessage && (actionConfig.type === "save" || !actionConfig.successMessage.includes("저장")); - + const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage; toast.success(successMessage); @@ -539,7 +530,8 @@ export const ButtonPrimaryComponent: React.FC = ({ alignItems: "center", justifyContent: "center", // 🔧 크기에 따른 패딩 조정 - padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", + padding: + componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", lineHeight: "1.25", boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 22486ac9..f6205057 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { toast } from "sonner"; import { screenApi } from "@/lib/api/screen"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; @@ -15,7 +16,8 @@ export type ButtonActionType = | "edit" // 편집 | "navigate" // 페이지 이동 | "modal" // 모달 열기 - | "control"; // 제어 흐름 + | "control" // 제어 흐름 + | "view_table_history"; // 테이블 이력 보기 /** * 버튼 액션 설정 @@ -46,6 +48,13 @@ export interface ButtonActionConfig { enableDataflowControl?: boolean; dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용) dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍 + + // 테이블 이력 보기 관련 + historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정) + historyRecordIdField?: string; // PK 필드명 (기본: "id") + historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스 + historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항) + historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) } /** @@ -64,7 +73,7 @@ export interface ButtonActionContext { // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; - + // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; @@ -105,6 +114,9 @@ export class ButtonActionExecutor { case "control": return this.handleControl(config, context); + case "view_table_history": + return this.handleViewTableHistory(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -932,7 +944,7 @@ export class ButtonActionExecutor { console.log("🔄 플로우 새로고침 호출"); context.onFlowRefresh(); } - + // 테이블 새로고침 (일반 테이블용) if (context.onRefresh) { console.log("🔄 테이블 새로고침 호출"); @@ -1473,6 +1485,113 @@ export class ButtonActionExecutor { } } + /** + * 테이블 이력 보기 액션 처리 + */ + private static async handleViewTableHistory( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { + console.log("📜 테이블 이력 보기 액션 실행:", { config, context }); + + // 테이블명 결정 (설정 > 컨텍스트 > 폼 데이터) + const tableName = config.historyTableName || context.tableName; + if (!tableName) { + toast.error("테이블명이 지정되지 않았습니다."); + return false; + } + + // 레코드 ID 가져오기 (선택사항 - 없으면 전체 테이블 이력) + const recordIdField = config.historyRecordIdField || "id"; + const recordIdSource = config.historyRecordIdSource || "selected_row"; + + let recordId: any = null; + let recordLabel: string | undefined; + + switch (recordIdSource) { + case "selected_row": + // 선택된 행에서 가져오기 (선택사항) + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + const selectedRow = context.selectedRowsData[0]; + recordId = selectedRow[recordIdField]; + + // 라벨 필드가 지정되어 있으면 사용 + if (config.historyRecordLabelField) { + recordLabel = selectedRow[config.historyRecordLabelField]; + } + } else if (context.flowSelectedData && context.flowSelectedData.length > 0) { + // 플로우 선택 데이터 폴백 + const selectedRow = context.flowSelectedData[0]; + recordId = selectedRow[recordIdField]; + + if (config.historyRecordLabelField) { + recordLabel = selectedRow[config.historyRecordLabelField]; + } + } + break; + + case "form_field": + // 폼 필드에서 가져오기 + recordId = context.formData?.[recordIdField]; + if (config.historyRecordLabelField) { + recordLabel = context.formData?.[config.historyRecordLabelField]; + } + break; + + case "context": + // 원본 데이터에서 가져오기 + recordId = context.originalData?.[recordIdField]; + if (config.historyRecordLabelField) { + recordLabel = context.originalData?.[config.historyRecordLabelField]; + } + break; + } + + // recordId가 없어도 괜찮음 - 전체 테이블 이력 보기 + console.log("📋 이력 조회 대상:", { + tableName, + recordId: recordId || "전체", + recordLabel, + mode: recordId ? "단일 레코드" : "전체 테이블", + }); + + // 이력 모달 열기 (동적 import) + try { + const { TableHistoryModal } = await import("@/components/common/TableHistoryModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(TableHistoryModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + tableName, + recordId, + recordLabel, + displayColumn: config.historyDisplayColumn, + }), + ); + + return true; + } catch (error) { + console.error("❌ 이력 모달 열기 실패:", error); + toast.error("이력 조회 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -1539,4 +1658,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record