로그시스템 개선

This commit is contained in:
kjs 2025-10-27 11:11:08 +09:00
parent f14d9ee66c
commit 5fdefffd26
12 changed files with 1588 additions and 93 deletions

View File

@ -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);
}
// 배치 스케줄러 초기화
@ -283,7 +292,8 @@ app.listen(PORT, HOST, async () => {
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);

View File

@ -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<void> {
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<any>(historyQuery, queryParams),
query<any>(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<void> {
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<any>(historyQuery, queryParams),
query<any>(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<void> {
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<any>(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<void> {
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<any>(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<void> {
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<any>(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,
});
}
}
}

View File

@ -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(" 이력 테이블이 이미 존재합니다.");
}
}
}

View File

@ -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;

View File

@ -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);
// 테이블 라벨 상태

View File

@ -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<TableHistoryTimelineEvent[]>([]);
const [detailRecords, setDetailRecords] = useState<TableHistoryRecord[]>([]);
const [allRecords, setAllRecords] = useState<TableHistoryRecord[]>([]); // 검색용 원본 데이터
// recordId가 없으면 (전체 테이블 모드) detail 탭부터 시작
const [activeTab, setActiveTab] = useState<"timeline" | "detail">(recordId ? "timeline" : "detail");
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState<string>(""); // 검색어
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 <Plus className="h-4 w-4 text-green-600" />;
case "UPDATE":
return <FileEdit className="h-4 w-4 text-blue-600" />;
case "DELETE":
return <Trash2 className="h-4 w-4 text-red-600" />;
default:
return <Clock className="h-4 w-4 text-gray-600" />;
}
};
const getOperationBadge = (type: string) => {
switch (type) {
case "INSERT":
return <Badge className="bg-green-100 text-xs text-green-800"></Badge>;
case "UPDATE":
return <Badge className="bg-blue-100 text-xs text-blue-800"></Badge>;
case "DELETE":
return <Badge className="bg-red-100 text-xs text-red-800"></Badge>;
default:
return (
<Badge variant="secondary" className="text-xs">
{type}
</Badge>
);
}
};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-[95vw] sm:max-w-[900px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Clock className="h-5 w-5" />
{" "}
{!recordId && (
<Badge variant="secondary" className="text-xs">
</Badge>
)}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{recordId
? `${recordLabel || `레코드 ID: ${recordId}`} - ${tableName} 테이블`
: `${tableName} 테이블 전체 이력`}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-12">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<p className="text-destructive text-sm">{error}</p>
<Button variant="outline" onClick={loadHistory} className="mt-4 h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</div>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="w-full">
{recordId && (
<TabsList className="w-full">
<TabsTrigger value="timeline" className="flex-1 text-xs sm:text-sm">
({timeline.length})
</TabsTrigger>
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
({detailRecords.length})
</TabsTrigger>
</TabsList>
)}
{!recordId && (
<>
<TabsList className="w-full">
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
({detailRecords.length})
</TabsTrigger>
</TabsList>
{/* 검색창 (전체 테이블 모드) */}
<div className="relative mt-4">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={`레코드 ID${displayColumn ? `, ${displayColumn}` : ""}, 변경자, 컬럼명으로 검색...`}
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm"
/>
{searchTerm && (
<button
onClick={() => handleSearch("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{searchTerm && (
<p className="text-muted-foreground mt-2 text-xs">
: {detailRecords.length} / {allRecords.length}
</p>
)}
</>
)}
{/* 타임라인 뷰 */}
<TabsContent value="timeline">
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
{timeline.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="text-muted-foreground mb-2 h-12 w-12" />
<p className="text-muted-foreground text-sm"> </p>
</div>
) : (
<div className="space-y-6">
{timeline.map((event, index) => (
<div key={index} className="relative border-l-2 border-gray-200 pb-6 pl-8 last:border-l-0">
<div className="absolute top-0 -left-3 rounded-full border-2 border-gray-200 bg-white p-1">
{getOperationIcon(event.operation_type)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
{getOperationBadge(event.operation_type)}
<span className="text-muted-foreground text-xs">{formatDate(event.changed_at)}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<User className="text-muted-foreground h-3 w-3" />
<span className="font-medium">{event.changed_by}</span>
{event.ip_address && (
<span className="text-muted-foreground text-xs">({event.ip_address})</span>
)}
</div>
{event.changes && event.changes.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-muted-foreground text-xs font-medium"> :</p>
<div className="space-y-1">
{event.changes.map((change, idx) => (
<div key={idx} className="rounded bg-gray-50 p-2 text-xs">
<span className="font-mono font-medium">{change.column}</span>
<div className="mt-1 flex items-center gap-2">
<span className="text-red-600 line-through">{change.oldValue || "(없음)"}</span>
<span></span>
<span className="font-medium text-green-600">{change.newValue || "(없음)"}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</TabsContent>
{/* 상세 내역 뷰 */}
<TabsContent value="detail">
<ScrollArea className="h-[500px] w-full rounded-md border">
{detailRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FileEdit className="text-muted-foreground mb-2 h-12 w-12" />
<p className="text-muted-foreground text-sm"> </p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 border-b bg-gray-50">
<tr>
{!recordId && <th className="p-2 text-left font-medium"></th>}
<th className="p-2 text-left font-medium"></th>
<th className="p-2 text-left font-medium"></th>
<th className="p-2 text-left font-medium"> </th>
<th className="p-2 text-left font-medium"> </th>
<th className="p-2 text-left font-medium"></th>
<th className="p-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{detailRecords.map((record) => {
const displayValue = getDisplayValue(record);
return (
<tr key={record.log_id} className="border-b hover:bg-gray-50">
{!recordId && (
<td className="p-2">
{displayValue ? (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{displayValue}</span>
<span className="text-xs text-gray-500">(ID: {record.original_id})</span>
</div>
) : (
<span className="font-mono font-medium text-blue-600">{record.original_id}</span>
)}
</td>
)}
<td className="p-2">{getOperationBadge(record.operation_type)}</td>
<td className="p-2 font-mono">{record.changed_column}</td>
<td className="max-w-[200px] truncate p-2 text-red-600">{record.old_value || "-"}</td>
<td className="max-w-[200px] truncate p-2 text-green-600">{record.new_value || "-"}</td>
<td className="p-2">{record.changed_by}</td>
<td className="text-muted-foreground p-2">{formatDate(record.changed_at)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</ScrollArea>
</TabsContent>
</Tabs>
)}
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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 {
@ -31,16 +33,19 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
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<ButtonConfigPanelProps> = ({
const [modalSearchTerm, setModalSearchTerm] = useState("");
const [navSearchTerm, setNavSearchTerm] = useState("");
// 테이블 컬럼 목록 상태
const [tableColumns, setTableColumns] = useState<string[]>([]);
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<ButtonConfigPanelProps> = ({
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;
@ -211,6 +331,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
</SelectContent>
</Select>
</div>
@ -476,6 +597,162 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 테이블 이력 보기 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
<h4 className="text-sm font-medium text-blue-900">📜 </h4>
<div>
<Label htmlFor="history-table-name"> ()</Label>
<Input
id="history-table-name"
placeholder="자동 감지 (비워두면 현재 화면의 테이블 사용)"
value={config.action?.historyTableName || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyTableName", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600"> </p>
</div>
<div>
<Label htmlFor="history-record-id-field"> ID </Label>
<Input
id="history-record-id-field"
placeholder="id (기본값)"
value={config.action?.historyRecordIdField || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyRecordIdField", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600"> . &quot;id&quot;.</p>
</div>
<div>
<Label htmlFor="history-record-id-source"> ID </Label>
<Select
value={config.action?.historyRecordIdSource || "selected_row"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.historyRecordIdSource", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="레코드 ID 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="selected_row"> ()</SelectItem>
<SelectItem value="form_field"> </SelectItem>
<SelectItem value="context"> ( )</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-600"> ID를 </p>
</div>
<div>
<Label htmlFor="history-record-label-field"> ()</Label>
<Input
id="history-record-label-field"
placeholder="예: name, title, device_code 등"
value={config.action?.historyRecordLabelField || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.historyRecordLabelField", e.target.value);
}}
/>
<p className="mt-1 text-xs text-gray-600">
&quot;ID 123 &quot; &quot; &quot;
</p>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<Label className="text-blue-900">
() <span className="text-red-600">*</span>
</Label>
{!config.action?.historyTableName && !currentTableName ? (
<div className="mt-2 rounded-md border border-yellow-300 bg-yellow-50 p-3">
<p className="text-xs text-yellow-800">
<strong></strong> , .
</p>
</div>
) : (
<>
{!config.action?.historyTableName && currentTableName && (
<div className="mt-2 rounded-md border border-green-300 bg-green-50 p-2">
<p className="text-xs text-green-800">
<strong>{currentTableName}</strong>() .
</p>
</div>
)}
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={displayColumnOpen}
className="mt-2 h-10 w-full justify-between text-sm"
disabled={columnsLoading || tableColumns.length === 0}
>
{columnsLoading
? "로딩 중..."
: config.action?.historyDisplayColumn
? config.action.historyDisplayColumn
: tableColumns.length === 0
? "사용 가능한 컬럼이 없습니다"
: "컬럼을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-sm" />
<CommandList>
<CommandEmpty className="text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column}
value={column}
onSelect={(currentValue) => {
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
setDisplayColumnOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
)}
/>
{column}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-2 text-xs text-gray-700">
<strong> </strong> .
<br />
: <code className="rounded bg-white px-1">device_code</code> &quot; ID: 5&quot;
&quot;DTG-001 (ID: 5)&quot; .
<br /> .
</p>
{tableColumns.length === 0 && !columnsLoading && (
<p className="mt-2 text-xs text-red-600">
ID .
</p>
)}
</>
)}
</div>
</div>
)}
{/* 페이지 이동 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
@ -589,4 +866,3 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
);
};

View File

@ -50,13 +50,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
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<DetailSettingsPanelProps> = ({
case "button-primary":
case "button-secondary":
// 🔧 component.id만 key로 사용 (unmount 방지)
return <NewButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
return (
<NewButtonConfigPanel
key={selectedComponent.id}
component={selectedComponent}
onUpdateProperty={handleUpdateProperty}
currentTableName={currentTableName}
/>
);
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;

View File

@ -108,13 +108,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{currentResolution && onResolutionChange && (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="h-3 w-3 text-primary" />
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<ResolutionPanel
currentResolution={currentResolution}
onResolutionChange={onResolutionChange}
/>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
)}
@ -156,7 +153,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
case "button-primary":
case "button-secondary":
// 🔧 component.id만 key로 사용 (unmount 방지)
return <ButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} allComponents={allComponents} />;
return (
<ButtonConfigPanel
key={selectedComponent.id}
component={selectedComponent}
onUpdateProperty={handleUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
);
case "card":
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
@ -198,12 +203,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
return (
<div className="space-y-1.5">
{/* 컴포넌트 정보 - 간소화 */}
<div className="flex items-center justify-between rounded bg-muted px-2 py-1">
<div className="bg-muted flex items-center justify-between rounded px-2 py-1">
<div className="flex items-center gap-1">
<Info className="h-2.5 w-2.5 text-muted-foreground" />
<span className="text-[10px] font-medium text-foreground">{selectedComponent.type}</span>
<Info className="text-muted-foreground h-2.5 w-2.5" />
<span className="text-foreground text-[10px] font-medium">{selectedComponent.type}</span>
</div>
<span className="text-[9px] text-muted-foreground">{selectedComponent.id.slice(0, 8)}</span>
<span className="text-muted-foreground text-[9px]">{selectedComponent.id.slice(0, 8)}</span>
</div>
{/* 라벨 + 최소 높이 (같은 행) */}
@ -609,7 +614,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 헤더 - 간소화 */}
<div className="border-b border-gray-200 px-3 py-2">
{selectedComponent.type === "widget" && (
<div className="text-[10px] text-gray-600 truncate">
<div className="truncate text-[10px] text-gray-600">
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
</div>
)}
@ -623,13 +628,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<>
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Monitor className="h-3 w-3 text-primary" />
<Monitor className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<ResolutionPanel
currentResolution={currentResolution}
onResolutionChange={onResolutionChange}
/>
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
</div>
<Separator className="my-2" />
</>
@ -648,7 +650,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Palette className="h-3 w-3 text-primary" />
<Palette className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4>
</div>
<StyleEditor

View File

@ -0,0 +1,177 @@
/**
* API
*/
import { apiClient } from "./client";
export interface TableHistoryRecord {
log_id: number;
operation_type: "INSERT" | "UPDATE" | "DELETE";
original_id: string;
changed_column: string;
old_value: string | null;
new_value: string | null;
changed_by: string;
changed_at: string;
ip_address: string | null;
user_agent: string | null;
full_row_before: Record<string, any> | null;
full_row_after: Record<string, any> | 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<string, any> | null;
full_row_after: Record<string, any> | 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<TableHistoryResponse> {
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<TableHistoryTimelineResponse> {
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<TableHistorySummaryResponse> {
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<TableHistoryCheckResponse> {
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 || "이력 테이블 확인 중 오류가 발생했습니다.",
};
}
}

View File

@ -187,12 +187,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<ButtonPrimaryComponentProps> = ({
};
}
// 스타일 계산
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
const componentStyle: React.CSSProperties = {
@ -223,7 +216,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...style,
};
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
if (isDesignMode) {
componentStyle.borderWidth = "1px";
@ -292,8 +284,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 1. 커스텀 메시지가 있고
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
const useCustomMessage =
actionConfig.errorMessage &&
(actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
@ -539,7 +530,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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`,

View File

@ -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)
}
/**
@ -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;
@ -1473,6 +1485,113 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static async handleViewTableHistory(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
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<ButtonActionType, Partial<ButtonActi
control: {
type: "control",
},
view_table_history: {
type: "view_table_history",
historyRecordIdField: "id",
historyRecordIdSource: "selected_row",
},
};