Compare commits
39 Commits
0198426c46
...
96df465a7d
| Author | SHA1 | Date |
|---|---|---|
|
|
96df465a7d | |
|
|
eb1cac4a77 | |
|
|
45de532b84 | |
|
|
fc0bc3e5c8 | |
|
|
1470bb2e73 | |
|
|
01f92d6132 | |
|
|
e290076708 | |
|
|
63553e23b1 | |
|
|
d63e092245 | |
|
|
0823874ebc | |
|
|
774332558b | |
|
|
b62f2ffc10 | |
|
|
40d8bcfe0f | |
|
|
1d0c4fe503 | |
|
|
7ec60bed6c | |
|
|
76ad3d9c43 | |
|
|
3033b02634 | |
|
|
9cb705dba8 | |
|
|
10d112bd69 | |
|
|
3fd325972f | |
|
|
8c18555305 | |
|
|
2305b8dfae | |
|
|
d3c9a42525 | |
|
|
8a2aa49910 | |
|
|
71111ce072 | |
|
|
55601481d7 | |
|
|
ec853fb45d | |
|
|
eac43cfb31 | |
|
|
5ca0a6b6dc | |
|
|
d57756189f | |
|
|
eadff1a051 | |
|
|
656f1c2ebd | |
|
|
fa30763ae2 | |
|
|
7fe246bd93 | |
|
|
c6465d3138 | |
|
|
5d260c7716 | |
|
|
74d287daa9 | |
|
|
874cf485a8 | |
|
|
687dccb522 |
|
|
@ -24,6 +24,8 @@ export class DashboardController {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -89,7 +91,8 @@ export class DashboardController {
|
||||||
|
|
||||||
const savedDashboard = await DashboardService.createDashboard(
|
const savedDashboard = await DashboardService.createDashboard(
|
||||||
dashboardData,
|
dashboardData,
|
||||||
userId
|
userId,
|
||||||
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||||
|
|
@ -121,6 +124,7 @@ export class DashboardController {
|
||||||
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
const query: DashboardListQuery = {
|
const query: DashboardListQuery = {
|
||||||
page: parseInt(req.query.page as string) || 1,
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
|
@ -145,7 +149,11 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(query, userId);
|
const result = await DashboardService.getDashboards(
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -173,6 +181,7 @@ export class DashboardController {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -182,7 +191,11 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = await DashboardService.getDashboardById(id, userId);
|
const dashboard = await DashboardService.getDashboardById(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
if (!dashboard) {
|
if (!dashboard) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -393,6 +406,8 @@ export class DashboardController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
const query: DashboardListQuery = {
|
const query: DashboardListQuery = {
|
||||||
page: parseInt(req.query.page as string) || 1,
|
page: parseInt(req.query.page as string) || 1,
|
||||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
|
@ -401,7 +416,11 @@ export class DashboardController {
|
||||||
createdBy: userId, // 본인이 만든 대시보드만
|
createdBy: userId, // 본인이 만든 대시보드만
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await DashboardService.getDashboards(query, userId);
|
const result = await DashboardService.getDashboards(
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,18 @@ export const saveFormData = async (
|
||||||
formDataWithMeta.company_code = companyCode;
|
formDataWithMeta.company_code = companyCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 클라이언트 IP 주소 추출
|
||||||
|
const ipAddress =
|
||||||
|
req.ip ||
|
||||||
|
(req.headers["x-forwarded-for"] as string) ||
|
||||||
|
req.socket.remoteAddress ||
|
||||||
|
"unknown";
|
||||||
|
|
||||||
const result = await dynamicFormService.saveFormData(
|
const result = await dynamicFormService.saveFormData(
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
formDataWithMeta
|
formDataWithMeta,
|
||||||
|
ipAddress
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -1048,3 +1048,268 @@ export async function updateColumnWebType(
|
||||||
res.status(500).json(response);
|
res.status(500).json(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 🎯 테이블 로그 시스템 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
export async function createLogTable(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { pkColumn } = req.body;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "PK 컬럼 정보가 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_PK_COLUMN",
|
||||||
|
details: "PK 컬럼명과 데이터 타입이 필요합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
await tableManagementService.createLogTable(tableName, pkColumn, userId);
|
||||||
|
|
||||||
|
logger.info(`로그 테이블 생성 완료: ${tableName}_log`);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 테이블이 성공적으로 생성되었습니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 테이블 생성 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "로그 테이블 생성 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "LOG_TABLE_CREATE_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 설정 조회
|
||||||
|
*/
|
||||||
|
export async function getLogConfig(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 설정 조회: ${tableName} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
const logConfig = await tableManagementService.getLogConfig(tableName);
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof logConfig> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 설정을 조회했습니다.",
|
||||||
|
data: logConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 설정 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "로그 설정 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "LOG_CONFIG_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 데이터 조회
|
||||||
|
*/
|
||||||
|
export async function getLogData(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
size = 20,
|
||||||
|
operationType,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
changedBy,
|
||||||
|
originalId,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 데이터 조회: ${tableName} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
const result = await tableManagementService.getLogData(tableName, {
|
||||||
|
page: parseInt(page as string),
|
||||||
|
size: parseInt(size as string),
|
||||||
|
operationType: operationType as string,
|
||||||
|
startDate: startDate as string,
|
||||||
|
endDate: endDate as string,
|
||||||
|
changedBy: changedBy as string,
|
||||||
|
originalId: originalId as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof result> = {
|
||||||
|
success: true,
|
||||||
|
message: "로그 데이터를 조회했습니다.",
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 데이터 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "로그 데이터 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "LOG_DATA_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 활성화/비활성화
|
||||||
|
*/
|
||||||
|
export async function toggleLogTable(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { isActive } = req.body;
|
||||||
|
|
||||||
|
logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_TABLE_NAME",
|
||||||
|
details: "테이블명 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive === undefined || isActive === null) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "isActive 값이 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_IS_ACTIVE",
|
||||||
|
details: "isActive 파라미터가 누락되었습니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
await tableManagementService.toggleLogTable(
|
||||||
|
tableName,
|
||||||
|
isActive === "Y" || isActive === true
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: true,
|
||||||
|
message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("로그 테이블 토글 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "로그 테이블 토글 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "LOG_TOGGLE_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ import {
|
||||||
checkTableExists,
|
checkTableExists,
|
||||||
getColumnWebTypes,
|
getColumnWebTypes,
|
||||||
checkDatabaseConnection,
|
checkDatabaseConnection,
|
||||||
|
createLogTable,
|
||||||
|
getLogConfig,
|
||||||
|
getLogData,
|
||||||
|
toggleLogTable,
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData);
|
||||||
*/
|
*/
|
||||||
router.delete("/tables/:tableName/delete", deleteTableData);
|
router.delete("/tables/:tableName/delete", deleteTableData);
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 테이블 로그 시스템 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
* POST /api/table-management/tables/:tableName/log
|
||||||
|
*/
|
||||||
|
router.post("/tables/:tableName/log", createLogTable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 설정 조회
|
||||||
|
* GET /api/table-management/tables/:tableName/log/config
|
||||||
|
*/
|
||||||
|
router.get("/tables/:tableName/log/config", getLogConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 데이터 조회
|
||||||
|
* GET /api/table-management/tables/:tableName/log
|
||||||
|
*/
|
||||||
|
router.get("/tables/:tableName/log", getLogData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 활성화/비활성화
|
||||||
|
* POST /api/table-management/tables/:tableName/log/toggle
|
||||||
|
*/
|
||||||
|
router.post("/tables/:tableName/log/toggle", toggleLogTable);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ export class DashboardService {
|
||||||
*/
|
*/
|
||||||
static async createDashboard(
|
static async createDashboard(
|
||||||
data: CreateDashboardRequest,
|
data: CreateDashboardRequest,
|
||||||
userId: string
|
userId: string,
|
||||||
|
companyCode?: string
|
||||||
): Promise<Dashboard> {
|
): Promise<Dashboard> {
|
||||||
const dashboardId = uuidv4();
|
const dashboardId = uuidv4();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -31,8 +32,8 @@ export class DashboardService {
|
||||||
`
|
`
|
||||||
INSERT INTO dashboards (
|
INSERT INTO dashboards (
|
||||||
id, title, description, is_public, created_by,
|
id, title, description, is_public, created_by,
|
||||||
created_at, updated_at, tags, category, view_count, settings
|
created_at, updated_at, tags, category, view_count, settings, company_code
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
dashboardId,
|
dashboardId,
|
||||||
|
|
@ -46,6 +47,7 @@ export class DashboardService {
|
||||||
data.category || null,
|
data.category || null,
|
||||||
0,
|
0,
|
||||||
JSON.stringify(data.settings || {}),
|
JSON.stringify(data.settings || {}),
|
||||||
|
companyCode || "DEFAULT",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -143,7 +145,11 @@ export class DashboardService {
|
||||||
/**
|
/**
|
||||||
* 대시보드 목록 조회
|
* 대시보드 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getDashboards(query: DashboardListQuery, userId?: string) {
|
static async getDashboards(
|
||||||
|
query: DashboardListQuery,
|
||||||
|
userId?: string,
|
||||||
|
companyCode?: string
|
||||||
|
) {
|
||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
|
|
@ -161,6 +167,13 @@ export class DashboardService {
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 회사 코드 필터링 (최우선)
|
||||||
|
if (companyCode) {
|
||||||
|
whereConditions.push(`d.company_code = $${paramIndex}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
// 권한 필터링
|
// 권한 필터링
|
||||||
if (userId) {
|
if (userId) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
|
|
@ -278,7 +291,8 @@ export class DashboardService {
|
||||||
*/
|
*/
|
||||||
static async getDashboardById(
|
static async getDashboardById(
|
||||||
dashboardId: string,
|
dashboardId: string,
|
||||||
userId?: string
|
userId?: string,
|
||||||
|
companyCode?: string
|
||||||
): Promise<Dashboard | null> {
|
): Promise<Dashboard | null> {
|
||||||
try {
|
try {
|
||||||
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
|
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
|
||||||
|
|
@ -286,21 +300,43 @@ export class DashboardService {
|
||||||
let dashboardParams: any[];
|
let dashboardParams: any[];
|
||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
dashboardQuery = `
|
if (companyCode) {
|
||||||
SELECT d.*
|
dashboardQuery = `
|
||||||
FROM dashboards d
|
SELECT d.*
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
FROM dashboards d
|
||||||
AND (d.created_by = $2 OR d.is_public = true)
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
`;
|
AND d.company_code = $2
|
||||||
dashboardParams = [dashboardId, userId];
|
AND (d.created_by = $3 OR d.is_public = true)
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, companyCode, userId];
|
||||||
|
} else {
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND (d.created_by = $2 OR d.is_public = true)
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, userId];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dashboardQuery = `
|
if (companyCode) {
|
||||||
SELECT d.*
|
dashboardQuery = `
|
||||||
FROM dashboards d
|
SELECT d.*
|
||||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
FROM dashboards d
|
||||||
AND d.is_public = true
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
`;
|
AND d.company_code = $2
|
||||||
dashboardParams = [dashboardId];
|
AND d.is_public = true
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, companyCode];
|
||||||
|
} else {
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND d.is_public = true
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardResult = await PostgreSQLService.query(
|
const dashboardResult = await PostgreSQLService.query(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
|
|
||||||
|
|
@ -203,7 +203,8 @@ export class DynamicFormService {
|
||||||
async saveFormData(
|
async saveFormData(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: Record<string, any>
|
data: Record<string, any>,
|
||||||
|
ipAddress?: string
|
||||||
): Promise<FormDataResult> {
|
): Promise<FormDataResult> {
|
||||||
try {
|
try {
|
||||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||||
|
|
@ -432,7 +433,19 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
||||||
console.log("📊 SQL 파라미터:", values);
|
console.log("📊 SQL 파라미터:", values);
|
||||||
|
|
||||||
const result = await query<any>(upsertQuery, values);
|
// 로그 트리거를 위한 세션 변수 설정 및 UPSERT 실행 (트랜잭션 내에서)
|
||||||
|
const userId = data.updated_by || data.created_by || "system";
|
||||||
|
const clientIp = ipAddress || "unknown";
|
||||||
|
|
||||||
|
const result = await transaction(async (client) => {
|
||||||
|
// 세션 변수 설정
|
||||||
|
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||||
|
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||||
|
|
||||||
|
// UPSERT 실행
|
||||||
|
const res = await client.query(upsertQuery, values);
|
||||||
|
return res.rows;
|
||||||
|
});
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,35 @@ export class RiskAlertCacheService {
|
||||||
*/
|
*/
|
||||||
public startAutoRefresh(): void {
|
public startAutoRefresh(): void {
|
||||||
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
|
console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)');
|
||||||
|
console.log(' - 기상특보: 즉시 호출');
|
||||||
|
console.log(' - 교통사고/도로공사: 10분 후 첫 호출');
|
||||||
|
|
||||||
// 즉시 첫 갱신
|
// 기상특보만 즉시 호출 (ITS API는 10분 후부터)
|
||||||
this.refreshCache();
|
this.refreshWeatherOnly();
|
||||||
|
|
||||||
// 10분마다 갱신 (600,000ms)
|
// 10분마다 전체 갱신 (600,000ms)
|
||||||
this.updateInterval = setInterval(() => {
|
this.updateInterval = setInterval(() => {
|
||||||
this.refreshCache();
|
this.refreshCache();
|
||||||
}, 10 * 60 * 1000);
|
}, 10 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기상특보만 갱신 (재시작 시 사용)
|
||||||
|
*/
|
||||||
|
private async refreshWeatherOnly(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('🌤️ 기상특보만 즉시 갱신 중...');
|
||||||
|
const weatherAlerts = await this.riskAlertService.getWeatherAlerts();
|
||||||
|
|
||||||
|
this.cachedAlerts = weatherAlerts;
|
||||||
|
this.lastUpdated = new Date();
|
||||||
|
|
||||||
|
console.log(`✅ 기상특보 갱신 완료! (${weatherAlerts.length}건)`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ 기상특보 갱신 실패:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자동 갱신 중지
|
* 자동 갱신 중지
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3118,4 +3118,410 @@ export class TableManagementService {
|
||||||
// 기본값
|
// 기본값
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 🎯 테이블 로그 시스템
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
async createLogTable(
|
||||||
|
tableName: string,
|
||||||
|
pkColumn: { columnName: string; dataType: string },
|
||||||
|
userId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
const triggerFuncName = `${tableName}_log_trigger_func`;
|
||||||
|
const triggerName = `${tableName}_audit_trigger`;
|
||||||
|
|
||||||
|
logger.info(`로그 테이블 생성 시작: ${logTableName}`);
|
||||||
|
|
||||||
|
// 로그 테이블 DDL 생성
|
||||||
|
const logTableDDL = this.generateLogTableDDL(
|
||||||
|
logTableName,
|
||||||
|
tableName,
|
||||||
|
pkColumn.columnName,
|
||||||
|
pkColumn.dataType
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트리거 함수 DDL 생성
|
||||||
|
const triggerFuncDDL = this.generateTriggerFunctionDDL(
|
||||||
|
triggerFuncName,
|
||||||
|
logTableName,
|
||||||
|
tableName,
|
||||||
|
pkColumn.columnName
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트리거 DDL 생성
|
||||||
|
const triggerDDL = this.generateTriggerDDL(
|
||||||
|
triggerName,
|
||||||
|
tableName,
|
||||||
|
triggerFuncName
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트랜잭션으로 실행
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 1. 로그 테이블 생성
|
||||||
|
await client.query(logTableDDL);
|
||||||
|
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
|
||||||
|
|
||||||
|
// 2. 트리거 함수 생성
|
||||||
|
await client.query(triggerFuncDDL);
|
||||||
|
logger.info(`트리거 함수 생성 완료: ${triggerFuncName}`);
|
||||||
|
|
||||||
|
// 3. 트리거 생성
|
||||||
|
await client.query(triggerDDL);
|
||||||
|
logger.info(`트리거 생성 완료: ${triggerName}`);
|
||||||
|
|
||||||
|
// 4. 로그 설정 저장
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO table_log_config (
|
||||||
|
original_table_name, log_table_name, trigger_name,
|
||||||
|
trigger_function_name, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[tableName, logTableName, triggerName, triggerFuncName, userId]
|
||||||
|
);
|
||||||
|
logger.info(`로그 설정 저장 완료: ${tableName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`로그 테이블 생성 완료: ${logTableName}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`로그 테이블 생성 실패: ${tableName}`, error);
|
||||||
|
throw new Error(
|
||||||
|
`로그 테이블 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateLogTableDDL(
|
||||||
|
logTableName: string,
|
||||||
|
originalTableName: string,
|
||||||
|
pkColumnName: string,
|
||||||
|
pkDataType: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE ${logTableName} (
|
||||||
|
log_id SERIAL PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(10) NOT NULL,
|
||||||
|
original_id VARCHAR(100),
|
||||||
|
changed_column VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by VARCHAR(50),
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
full_row_before JSONB,
|
||||||
|
full_row_after JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
|
||||||
|
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
|
||||||
|
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
|
||||||
|
|
||||||
|
COMMENT ON TABLE ${logTableName} IS '${originalTableName} 테이블 변경 이력';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.changed_by IS '변경자 ID';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.changed_at IS '변경 시각';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.ip_address IS '변경 요청 IP';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.full_row_before IS '변경 전 전체 행 (JSON)';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.full_row_after IS '변경 후 전체 행 (JSON)';
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리거 함수 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateTriggerFunctionDDL(
|
||||||
|
funcName: string,
|
||||||
|
logTableName: string,
|
||||||
|
originalTableName: string,
|
||||||
|
pkColumnName: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE OR REPLACE FUNCTION ${funcName}()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name TEXT;
|
||||||
|
v_old_value TEXT;
|
||||||
|
v_new_value TEXT;
|
||||||
|
v_user_id VARCHAR(50);
|
||||||
|
v_ip_address VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
v_user_id := current_setting('app.user_id', TRUE);
|
||||||
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||||
|
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_after)
|
||||||
|
VALUES ($1, ($2).%I, $3, $4, $5)',
|
||||||
|
'${pkColumnName}'
|
||||||
|
)
|
||||||
|
USING 'INSERT', NEW, v_user_id, v_ip_address, row_to_json(NEW)::jsonb;
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
FOR v_column_name IN
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = '${originalTableName}'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||||
|
INTO v_old_value, v_new_value
|
||||||
|
USING OLD, NEW;
|
||||||
|
|
||||||
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_column, old_value, new_value, changed_by, ip_address, full_row_before, full_row_after)
|
||||||
|
VALUES ($1, ($2).%I, $3, $4, $5, $6, $7, $8, $9)',
|
||||||
|
'${pkColumnName}'
|
||||||
|
)
|
||||||
|
USING 'UPDATE', NEW, v_column_name, v_old_value, v_new_value, v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
EXECUTE format(
|
||||||
|
'INSERT INTO ${logTableName} (operation_type, original_id, changed_by, ip_address, full_row_before)
|
||||||
|
VALUES ($1, ($2).%I, $3, $4, $5)',
|
||||||
|
'${pkColumnName}'
|
||||||
|
)
|
||||||
|
USING 'DELETE', OLD, v_user_id, v_ip_address, row_to_json(OLD)::jsonb;
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리거 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateTriggerDDL(
|
||||||
|
triggerName: string,
|
||||||
|
tableName: string,
|
||||||
|
funcName: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 설정 조회
|
||||||
|
*/
|
||||||
|
async getLogConfig(tableName: string): Promise<{
|
||||||
|
originalTableName: string;
|
||||||
|
logTableName: string;
|
||||||
|
triggerName: string;
|
||||||
|
triggerFunctionName: string;
|
||||||
|
isActive: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdBy: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
logger.info(`로그 설정 조회: ${tableName}`);
|
||||||
|
|
||||||
|
const result = await queryOne<{
|
||||||
|
original_table_name: string;
|
||||||
|
log_table_name: string;
|
||||||
|
trigger_name: string;
|
||||||
|
trigger_function_name: string;
|
||||||
|
is_active: string;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
original_table_name, log_table_name, trigger_name,
|
||||||
|
trigger_function_name, is_active, created_at, created_by
|
||||||
|
FROM table_log_config
|
||||||
|
WHERE original_table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalTableName: result.original_table_name,
|
||||||
|
logTableName: result.log_table_name,
|
||||||
|
triggerName: result.trigger_name,
|
||||||
|
triggerFunctionName: result.trigger_function_name,
|
||||||
|
isActive: result.is_active,
|
||||||
|
createdAt: result.created_at,
|
||||||
|
createdBy: result.created_by,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`로그 설정 조회 실패: ${tableName}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 데이터 조회
|
||||||
|
*/
|
||||||
|
async getLogData(
|
||||||
|
tableName: string,
|
||||||
|
options: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
operationType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
changedBy?: string;
|
||||||
|
originalId?: string;
|
||||||
|
}
|
||||||
|
): Promise<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
const offset = (options.page - 1) * options.size;
|
||||||
|
|
||||||
|
logger.info(`로그 데이터 조회: ${logTableName}`, options);
|
||||||
|
|
||||||
|
// WHERE 조건 구성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (options.operationType) {
|
||||||
|
whereConditions.push(`operation_type = $${paramIndex}`);
|
||||||
|
values.push(options.operationType);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.startDate) {
|
||||||
|
whereConditions.push(`changed_at >= $${paramIndex}::timestamp`);
|
||||||
|
values.push(options.startDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.endDate) {
|
||||||
|
whereConditions.push(`changed_at <= $${paramIndex}::timestamp`);
|
||||||
|
values.push(options.endDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.changedBy) {
|
||||||
|
whereConditions.push(`changed_by = $${paramIndex}`);
|
||||||
|
values.push(options.changedBy);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.originalId) {
|
||||||
|
whereConditions.push(`original_id::text = $${paramIndex}`);
|
||||||
|
values.push(options.originalId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countQuery = `SELECT COUNT(*) as count FROM ${logTableName} ${whereClause}`;
|
||||||
|
const countResult = await query<any>(countQuery, values);
|
||||||
|
const total = parseInt(countResult[0].count);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT * FROM ${logTableName}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await query<any>(dataQuery, [
|
||||||
|
...values,
|
||||||
|
options.size,
|
||||||
|
offset,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / options.size);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그 데이터 조회 완료: ${logTableName}, 총 ${total}건, ${data.length}개 반환`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
page: options.page,
|
||||||
|
size: options.size,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`로그 데이터 조회 실패: ${tableName}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 활성화/비활성화
|
||||||
|
*/
|
||||||
|
async toggleLogTable(tableName: string, isActive: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const logConfig = await this.getLogConfig(tableName);
|
||||||
|
if (!logConfig) {
|
||||||
|
throw new Error(`로그 설정을 찾을 수 없습니다: ${tableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"}: ${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 트리거 활성화/비활성화
|
||||||
|
if (isActive) {
|
||||||
|
await client.query(
|
||||||
|
`ALTER TABLE ${tableName} ENABLE TRIGGER ${logConfig.triggerName}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await client.query(
|
||||||
|
`ALTER TABLE ${tableName} DISABLE TRIGGER ${logConfig.triggerName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 업데이트
|
||||||
|
await client.query(
|
||||||
|
`UPDATE table_log_config
|
||||||
|
SET is_active = $1, updated_at = NOW()
|
||||||
|
WHERE original_table_name = $2`,
|
||||||
|
[isActive ? "Y" : "N", tableName]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 완료: ${tableName}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`로그 테이블 ${isActive ? "활성화" : "비활성화"} 실패: ${tableName}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Dashboard } from "@/lib/api/dashboard";
|
import { Dashboard } from "@/lib/api/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -25,8 +23,9 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react";
|
import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
||||||
|
import { Plus, Search, Edit, Trash2, Copy, MoreVertical } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 관리 페이지
|
* 대시보드 관리 페이지
|
||||||
|
|
@ -35,27 +34,38 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu
|
||||||
*/
|
*/
|
||||||
export default function DashboardListPage() {
|
export default function DashboardListPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
// 페이지네이션 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null);
|
||||||
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
|
||||||
const [successMessage, setSuccessMessage] = useState("");
|
|
||||||
|
|
||||||
// 대시보드 목록 로드
|
// 대시보드 목록 로드
|
||||||
const loadDashboards = async () => {
|
const loadDashboards = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
const result = await dashboardApi.getMyDashboards({
|
||||||
const result = await dashboardApi.getMyDashboards({ search: searchTerm });
|
search: searchTerm,
|
||||||
|
page: currentPage,
|
||||||
|
limit: pageSize,
|
||||||
|
});
|
||||||
setDashboards(result.dashboards);
|
setDashboards(result.dashboards);
|
||||||
|
setTotalCount(result.pagination.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load dashboards:", err);
|
console.error("Failed to load dashboards:", err);
|
||||||
setError("대시보드 목록을 불러오는데 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 목록을 불러오는데 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +73,29 @@ export default function DashboardListPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
}, [searchTerm]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchTerm, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 페이지네이션 정보 계산
|
||||||
|
const paginationInfo: PaginationInfo = {
|
||||||
|
currentPage,
|
||||||
|
totalPages: Math.ceil(totalCount / pageSize),
|
||||||
|
totalItems: totalCount,
|
||||||
|
itemsPerPage: pageSize,
|
||||||
|
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||||
|
endItem: Math.min(currentPage * pageSize, totalCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 변경 핸들러
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 크기 변경 핸들러
|
||||||
|
const handlePageSizeChange = (size: number) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setCurrentPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||||
|
};
|
||||||
|
|
||||||
// 대시보드 삭제 확인 모달 열기
|
// 대시보드 삭제 확인 모달 열기
|
||||||
const handleDeleteClick = (id: string, title: string) => {
|
const handleDeleteClick = (id: string, title: string) => {
|
||||||
|
|
@ -79,37 +111,48 @@ export default function DashboardListPage() {
|
||||||
await dashboardApi.deleteDashboard(deleteTarget.id);
|
await dashboardApi.deleteDashboard(deleteTarget.id);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setSuccessMessage("대시보드가 삭제되었습니다.");
|
toast({
|
||||||
setSuccessDialogOpen(true);
|
title: "성공",
|
||||||
|
description: "대시보드가 삭제되었습니다.",
|
||||||
|
});
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete dashboard:", err);
|
console.error("Failed to delete dashboard:", err);
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setError("대시보드 삭제에 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 삭제에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 대시보드 복사
|
// 대시보드 복사
|
||||||
const handleCopy = async (dashboard: Dashboard) => {
|
const handleCopy = async (dashboard: Dashboard) => {
|
||||||
try {
|
try {
|
||||||
// 전체 대시보드 정보(요소 포함)를 가져오기
|
|
||||||
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
const fullDashboard = await dashboardApi.getDashboard(dashboard.id);
|
||||||
|
|
||||||
const newDashboard = await dashboardApi.createDashboard({
|
await dashboardApi.createDashboard({
|
||||||
title: `${fullDashboard.title} (복사본)`,
|
title: `${fullDashboard.title} (복사본)`,
|
||||||
description: fullDashboard.description,
|
description: fullDashboard.description,
|
||||||
elements: fullDashboard.elements || [],
|
elements: fullDashboard.elements || [],
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
tags: fullDashboard.tags,
|
tags: fullDashboard.tags,
|
||||||
category: fullDashboard.category,
|
category: fullDashboard.category,
|
||||||
settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사
|
settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "성공",
|
||||||
|
description: "대시보드가 복사되었습니다.",
|
||||||
});
|
});
|
||||||
setSuccessMessage("대시보드가 복사되었습니다.");
|
|
||||||
setSuccessDialogOpen(true);
|
|
||||||
loadDashboards();
|
loadDashboards();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy dashboard:", err);
|
console.error("Failed to copy dashboard:", err);
|
||||||
setError("대시보드 복사에 실패했습니다.");
|
toast({
|
||||||
|
title: "오류",
|
||||||
|
description: "대시보드 복사에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,35 +162,33 @@ export default function DashboardListPage() {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-full items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-sm font-medium">로딩 중...</div>
|
<div className="text-sm font-medium">로딩 중...</div>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">대시보드 목록을 불러오고 있습니다</div>
|
<div className="text-muted-foreground mt-2 text-xs">대시보드 목록을 불러오고 있습니다</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||||
<p className="text-sm text-muted-foreground">대시보드를 생성하고 관리할 수 있습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색 및 액션 */}
|
{/* 검색 및 액션 */}
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="relative w-full sm:w-[300px]">
|
<div className="relative w-full sm:w-[300px]">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="대시보드 검색..."
|
placeholder="대시보드 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -156,40 +197,22 @@ export default function DashboardListPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
<Button onClick={() => router.push("/admin/dashboard/new")} className="h-10 gap-2 text-sm font-medium">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||||
새 대시보드 생성
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 에러 메시지 */}
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-semibold text-destructive">오류가 발생했습니다</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
className="text-destructive transition-colors hover:text-destructive/80"
|
|
||||||
aria-label="에러 메시지 닫기"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-sm text-destructive/80">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 대시보드 목록 */}
|
{/* 대시보드 목록 */}
|
||||||
{dashboards.length === 0 ? (
|
{dashboards.length === 0 ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">대시보드가 없습니다</p>
|
<p className="text-muted-foreground text-sm">대시보드가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border bg-card shadow-sm">
|
<div className="bg-card rounded-lg border shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
|
||||||
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">제목</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">설명</TableHead>
|
||||||
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
<TableHead className="h-12 text-sm font-semibold">생성일</TableHead>
|
||||||
|
|
@ -199,13 +222,17 @@ export default function DashboardListPage() {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{dashboards.map((dashboard) => (
|
{dashboards.map((dashboard) => (
|
||||||
<TableRow key={dashboard.id} className="border-b transition-colors hover:bg-muted/50">
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||||
<TableCell className="h-16 max-w-md truncate text-sm text-muted-foreground">
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
{dashboard.description || "-"}
|
{dashboard.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.createdAt)}</TableCell>
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
<TableCell className="h-16 text-sm text-muted-foreground">{formatDate(dashboard.updatedAt)}</TableCell>
|
{formatDate(dashboard.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground h-16 text-sm">
|
||||||
|
{formatDate(dashboard.updatedAt)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="h-16 text-right">
|
<TableCell className="h-16 text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -227,7 +254,7 @@ export default function DashboardListPage() {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
onClick={() => handleDeleteClick(dashboard.id, dashboard.title)}
|
||||||
className="gap-2 text-sm text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
삭제
|
삭제
|
||||||
|
|
@ -241,6 +268,17 @@ export default function DashboardListPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && dashboards.length > 0 && (
|
||||||
|
<Pagination
|
||||||
|
paginationInfo={paginationInfo}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
|
showPageSizeSelector={true}
|
||||||
|
pageSizeOptions={[10, 20, 50, 100]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 삭제 확인 모달 */}
|
{/* 삭제 확인 모달 */}
|
||||||
|
|
@ -250,39 +288,20 @@ export default function DashboardListPage() {
|
||||||
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
<AlertDialogTitle className="text-base sm:text-lg">대시보드 삭제</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
"{deleteTarget?.title}" 대시보드를 삭제하시겠습니까?
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">취소</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDeleteConfirm}
|
onClick={handleDeleteConfirm}
|
||||||
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 성공 모달 */}
|
|
||||||
<Dialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
|
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<CheckCircle2 className="h-6 w-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-center text-base sm:text-lg">완료</DialogTitle>
|
|
||||||
<DialogDescription className="text-center text-xs sm:text-sm">{successMessage}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex justify-center pt-4">
|
|
||||||
<Button onClick={() => setSuccessDialogOpen(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
확인
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||||
|
import { TableLogViewer } from "@/components/admin/TableLogViewer";
|
||||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||||
|
|
||||||
interface TableInfo {
|
interface TableInfo {
|
||||||
|
|
@ -74,6 +75,10 @@ export default function TableManagementPage() {
|
||||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||||
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 로그 뷰어 상태
|
||||||
|
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||||
|
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
||||||
|
|
||||||
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
// 최고 관리자 여부 확인 (회사코드가 "*"인 경우)
|
||||||
const isSuperAdmin = user?.companyCode === "*";
|
const isSuperAdmin = user?.companyCode === "*";
|
||||||
|
|
||||||
|
|
@ -539,7 +544,7 @@ export default function TableManagementPage() {
|
||||||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="bg-background flex min-h-screen flex-col">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="space-y-2 border-b pb-4">
|
<div className="space-y-2 border-b pb-4">
|
||||||
|
|
@ -548,11 +553,14 @@ export default function TableManagementPage() {
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, "데이터베이스 테이블과 컬럼의 타입을 관리합니다")}
|
{getTextFromUI(
|
||||||
|
TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION,
|
||||||
|
"데이터베이스 테이블과 컬럼의 타입을 관리합니다",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<p className="mt-1 text-sm font-medium text-primary">
|
<p className="text-primary mt-1 text-sm font-medium">
|
||||||
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -571,20 +579,33 @@ export default function TableManagementPage() {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedTable && (
|
{selectedTable && (
|
||||||
<Button onClick={() => setAddColumnModalOpen(true)} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
<Button
|
||||||
|
onClick={() => setAddColumnModalOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
컬럼 추가
|
컬럼 추가
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={() => setDdlLogViewerOpen(true)} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
<Button
|
||||||
|
onClick={() => setDdlLogViewerOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
DDL 로그
|
DDL 로그
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button onClick={loadTables} disabled={loading} variant="outline" className="h-10 gap-2 text-sm font-medium">
|
<Button
|
||||||
|
onClick={loadTables}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.BUTTON_REFRESH, "새로고침")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -597,13 +618,13 @@ export default function TableManagementPage() {
|
||||||
<div className="w-[20%] border-r pr-6">
|
<div className="w-[20%] border-r pr-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<Database className="h-5 w-5 text-muted-foreground" />
|
<Database className="text-muted-foreground h-5 w-5" />
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
placeholder={getTextFromUI(TABLE_MANAGEMENT_KEYS.SEARCH_PLACEHOLDER, "테이블 검색...")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
|
|
@ -617,12 +638,12 @@ export default function TableManagementPage() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : tables.length === 0 ? (
|
) : tables.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -635,19 +656,17 @@ export default function TableManagementPage() {
|
||||||
.map((table) => (
|
.map((table) => (
|
||||||
<div
|
<div
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all ${
|
className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all ${
|
||||||
selectedTable === table.tableName
|
selectedTable === table.tableName ? "shadow-md" : "hover:shadow-md"
|
||||||
? "shadow-md"
|
|
||||||
: "hover:shadow-md"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleTableSelect(table.tableName)}
|
onClick={() => handleTableSelect(table.tableName)}
|
||||||
>
|
>
|
||||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||||
<span className="text-xs text-muted-foreground">컬럼</span>
|
<span className="text-muted-foreground text-xs">컬럼</span>
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{table.columnCount}
|
{table.columnCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -663,267 +682,278 @@ export default function TableManagementPage() {
|
||||||
<div className="w-[80%] pl-0">
|
<div className="w-[80%] pl-0">
|
||||||
<div className="flex h-full flex-col space-y-4">
|
<div className="flex h-full flex-col space-y-4">
|
||||||
<h2 className="flex items-center gap-2 text-xl font-semibold">
|
<h2 className="flex items-center gap-2 text-xl font-semibold">
|
||||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
<Settings className="text-muted-foreground h-5 w-5" />
|
||||||
{selectedTable ? <>테이블 설정 - {selectedTable}</> : "테이블 타입 관리"}
|
{selectedTable ? <>테이블 설정 - {selectedTable}</> : "테이블 타입 관리"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{!selectedTable ? (
|
{!selectedTable ? (
|
||||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||||
<div className="flex flex-col items-center gap-2 text-center">
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* 테이블 라벨 설정 */}
|
|
||||||
<div className="mb-4 flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={tableLabel}
|
|
||||||
onChange={(e) => setTableLabel(e.target.value)}
|
|
||||||
placeholder="테이블 표시명"
|
|
||||||
className="h-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
value={tableDescription}
|
|
||||||
onChange={(e) => setTableDescription(e.target.value)}
|
|
||||||
placeholder="테이블 설명"
|
|
||||||
className="h-10 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
{columnsLoading ? (
|
<>
|
||||||
<div className="flex items-center justify-center py-8">
|
{/* 테이블 라벨 설정 */}
|
||||||
<LoadingSpinner />
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<span className="ml-2 text-sm text-muted-foreground">
|
<div className="flex-1">
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
<Input
|
||||||
</span>
|
value={tableLabel}
|
||||||
</div>
|
onChange={(e) => setTableLabel(e.target.value)}
|
||||||
) : columns.length === 0 ? (
|
placeholder="테이블 표시명"
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
className="h-10 text-sm"
|
||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 컬럼 헤더 */}
|
|
||||||
<div className="flex items-center border-b pb-2 text-sm font-semibold text-foreground">
|
|
||||||
<div className="w-40 px-4">컬럼명</div>
|
|
||||||
<div className="w-48 px-4">라벨</div>
|
|
||||||
<div className="w-48 px-4">입력 타입</div>
|
|
||||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
|
||||||
상세 설정
|
|
||||||
</div>
|
|
||||||
<div className="w-80 px-4">설명</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
value={tableDescription}
|
||||||
|
onChange={(e) => setTableDescription(e.target.value)}
|
||||||
|
placeholder="테이블 설명"
|
||||||
|
className="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 리스트 */}
|
{columnsLoading ? (
|
||||||
<div
|
<div className="flex items-center justify-center py-8">
|
||||||
className="max-h-96 overflow-y-auto rounded-lg border"
|
<LoadingSpinner />
|
||||||
onScroll={(e) => {
|
<span className="text-muted-foreground ml-2 text-sm">
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")}
|
||||||
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
</span>
|
||||||
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
</div>
|
||||||
loadMoreColumns();
|
) : columns.length === 0 ? (
|
||||||
}
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
}}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
>
|
</div>
|
||||||
{columns.map((column, index) => (
|
) : (
|
||||||
<div
|
<div className="space-y-4">
|
||||||
key={column.columnName}
|
{/* 컬럼 헤더 */}
|
||||||
className="flex items-center border-b py-2 transition-colors hover:bg-muted/50"
|
<div className="text-foreground flex items-center border-b pb-2 text-sm font-semibold">
|
||||||
>
|
<div className="w-40 px-4">컬럼명</div>
|
||||||
<div className="w-40 px-4">
|
<div className="w-48 px-4">라벨</div>
|
||||||
<div className="font-mono text-sm">{column.columnName}</div>
|
<div className="w-48 px-4">입력 타입</div>
|
||||||
</div>
|
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||||
<div className="w-48 px-4">
|
상세 설정
|
||||||
<Input
|
</div>
|
||||||
value={column.displayName || ""}
|
<div className="w-80 px-4">설명</div>
|
||||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
</div>
|
||||||
placeholder={column.columnName}
|
|
||||||
className="h-8 text-xs"
|
{/* 컬럼 리스트 */}
|
||||||
/>
|
<div
|
||||||
</div>
|
className="max-h-96 overflow-y-auto rounded-lg border"
|
||||||
<div className="w-48 px-4">
|
onScroll={(e) => {
|
||||||
<Select
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
value={column.inputType || "text"}
|
// 스크롤이 끝에 가까워지면 더 많은 데이터 로드
|
||||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
if (scrollHeight - scrollTop <= clientHeight + 100) {
|
||||||
>
|
loadMoreColumns();
|
||||||
<SelectTrigger className="h-8 text-xs">
|
}
|
||||||
<SelectValue placeholder="입력 타입 선택" />
|
}}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
{columns.map((column, index) => (
|
||||||
{memoizedInputTypeOptions.map((option) => (
|
<div
|
||||||
<SelectItem key={option.value} value={option.value}>
|
key={column.columnName}
|
||||||
{option.label}
|
className="hover:bg-muted/50 flex items-center border-b py-2 transition-colors"
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
<div className="w-40 px-4">
|
||||||
</SelectContent>
|
<div className="font-mono text-sm">{column.columnName}</div>
|
||||||
</Select>
|
</div>
|
||||||
</div>
|
<div className="w-48 px-4">
|
||||||
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
<Input
|
||||||
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
|
value={column.displayName || ""}
|
||||||
{column.inputType === "code" && (
|
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||||
|
placeholder={column.columnName}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-48 px-4">
|
||||||
<Select
|
<Select
|
||||||
value={column.codeCategory || "none"}
|
value={column.inputType || "text"}
|
||||||
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
|
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="공통코드 선택" />
|
<SelectValue placeholder="입력 타입 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{commonCodeOptions.map((option, index) => (
|
{memoizedInputTypeOptions.map((option) => (
|
||||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
</div>
|
||||||
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
<div className="flex-1 px-4" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||||
{column.inputType === "entity" && (
|
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
|
||||||
<div className="space-y-1">
|
{column.inputType === "code" && (
|
||||||
{/* Entity 타입 설정 - 가로 배치 */}
|
<Select
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-2">
|
value={column.codeCategory || "none"}
|
||||||
<div className="mb-2 flex items-center gap-2">
|
onValueChange={(value) =>
|
||||||
<span className="text-xs font-medium text-primary">Entity 설정</span>
|
handleDetailSettingsChange(column.columnName, "code", value)
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
{/* 참조 테이블 */}
|
<SelectValue placeholder="공통코드 선택" />
|
||||||
<div>
|
</SelectTrigger>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">참조 테이블</label>
|
<SelectContent>
|
||||||
<Select
|
{commonCodeOptions.map((option, index) => (
|
||||||
value={column.referenceTable || "none"}
|
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||||
onValueChange={(value) =>
|
{option.label}
|
||||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
</SelectItem>
|
||||||
}
|
))}
|
||||||
>
|
</SelectContent>
|
||||||
<SelectTrigger className="h-8 bg-background text-xs">
|
</Select>
|
||||||
<SelectValue placeholder="선택" />
|
)}
|
||||||
</SelectTrigger>
|
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
<SelectContent>
|
{column.inputType === "entity" && (
|
||||||
{referenceTableOptions.map((option, index) => (
|
<div className="space-y-1">
|
||||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
{/* Entity 타입 설정 - 가로 배치 */}
|
||||||
<div className="flex flex-col">
|
<div className="border-primary/20 bg-primary/5 rounded-lg border p-2">
|
||||||
<span className="font-medium">{option.label}</span>
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<span className="text-xs text-muted-foreground">{option.value}</span>
|
<span className="text-primary text-xs font-medium">Entity 설정</span>
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 조인 컬럼 */}
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
{/* 참조 테이블 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">조인 컬럼</label>
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
|
참조 테이블
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceColumn || "none"}
|
value={column.referenceTable || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleDetailSettingsChange(
|
handleDetailSettingsChange(column.columnName, "entity", value)
|
||||||
column.columnName,
|
|
||||||
"entity_reference_column",
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 bg-background text-xs">
|
<SelectTrigger className="bg-background h-8 text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
{referenceTableOptions.map((option, index) => (
|
||||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={`ref-col-${refCol.columnName}-${index}`}
|
key={`entity-${option.value}-${index}`}
|
||||||
value={refCol.columnName}
|
value={option.value}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
<div className="flex flex-col">
|
||||||
</SelectItem>
|
<span className="font-medium">{option.label}</span>
|
||||||
))}
|
<span className="text-muted-foreground text-xs">
|
||||||
{(!referenceTableColumns[column.referenceTable] ||
|
{option.value}
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
</span>
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border border-primary border-t-transparent"></div>
|
|
||||||
로딩중
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* 조인 컬럼 */}
|
||||||
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
|
조인 컬럼
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={column.referenceColumn || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleDetailSettingsChange(
|
||||||
|
column.columnName,
|
||||||
|
"entity_reference_column",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background h-8 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
||||||
|
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
||||||
|
<SelectItem
|
||||||
|
key={`ref-col-${refCol.columnName}-${index}`}
|
||||||
|
value={refCol.columnName}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!referenceTableColumns[column.referenceTable] ||
|
||||||
|
referenceTableColumns[column.referenceTable].length === 0) && (
|
||||||
|
<SelectItem value="loading" disabled>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
|
로딩중
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 완료 표시 - 간소화 */}
|
||||||
|
{column.referenceTable &&
|
||||||
|
column.referenceTable !== "none" &&
|
||||||
|
column.referenceColumn &&
|
||||||
|
column.referenceColumn !== "none" &&
|
||||||
|
column.displayColumn &&
|
||||||
|
column.displayColumn !== "none" && (
|
||||||
|
<div className="bg-primary/10 text-primary mt-1 flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||||
|
<span>✓</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 완료 표시 - 간소화 */}
|
|
||||||
{column.referenceTable &&
|
|
||||||
column.referenceTable !== "none" &&
|
|
||||||
column.referenceColumn &&
|
|
||||||
column.referenceColumn !== "none" &&
|
|
||||||
column.displayColumn &&
|
|
||||||
column.displayColumn !== "none" && (
|
|
||||||
<div className="mt-1 flex items-center gap-1 rounded bg-primary/10 px-2 py-1 text-xs text-primary">
|
|
||||||
<span>✓</span>
|
|
||||||
<span className="truncate">
|
|
||||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{/* 다른 웹 타입인 경우 빈 공간 */}
|
||||||
{/* 다른 웹 타입인 경우 빈 공간 */}
|
{column.inputType !== "code" && column.inputType !== "entity" && (
|
||||||
{column.inputType !== "code" && column.inputType !== "entity" && (
|
<div className="text-muted-foreground flex h-8 items-center text-xs">-</div>
|
||||||
<div className="flex h-8 items-center text-xs text-muted-foreground">-</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
<div className="w-80 px-4">
|
||||||
|
<Input
|
||||||
|
value={column.description || ""}
|
||||||
|
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||||
|
placeholder="설명"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-80 px-4">
|
))}
|
||||||
<Input
|
|
||||||
value={column.description || ""}
|
|
||||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
|
||||||
placeholder="설명"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 로딩 표시 */}
|
|
||||||
{columnsLoading && (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<LoadingSpinner />
|
|
||||||
<span className="ml-2 text-sm text-muted-foreground">더 많은 컬럼 로딩 중...</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지 정보 */}
|
{/* 로딩 표시 */}
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
{columnsLoading && (
|
||||||
{columns.length} / {totalColumns} 컬럼 표시됨
|
<div className="flex items-center justify-center py-4">
|
||||||
</div>
|
<LoadingSpinner />
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm">더 많은 컬럼 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 전체 저장 버튼 */}
|
{/* 페이지 정보 */}
|
||||||
<div className="flex justify-end pt-4">
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
<Button
|
{columns.length} / {totalColumns} 컬럼 표시됨
|
||||||
onClick={saveAllSettings}
|
</div>
|
||||||
disabled={!selectedTable || columns.length === 0}
|
|
||||||
className="h-10 gap-2 text-sm font-medium"
|
{/* 전체 저장 버튼 */}
|
||||||
>
|
<div className="flex justify-end pt-4">
|
||||||
<Settings className="h-4 w-4" />
|
<Button
|
||||||
전체 설정 저장
|
onClick={saveAllSettings}
|
||||||
</Button>
|
disabled={!selectedTable || columns.length === 0}
|
||||||
|
className="h-10 gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
전체 설정 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -967,6 +997,9 @@ export default function TableManagementPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
<DDLLogViewer isOpen={ddlLogViewerOpen} onClose={() => setDdlLogViewerOpen(false)} />
|
||||||
|
|
||||||
|
{/* 테이블 로그 뷰어 */}
|
||||||
|
<TableLogViewer tableName={logViewerTableName} open={logViewerOpen} onOpenChange={setLogViewerOpen} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,12 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||||
import { ddlApi } from "../../lib/api/ddl";
|
import { ddlApi } from "../../lib/api/ddl";
|
||||||
|
import { tableManagementApi } from "../../lib/api/tableManagement";
|
||||||
import {
|
import {
|
||||||
CreateTableModalProps,
|
CreateTableModalProps,
|
||||||
CreateColumnDefinition,
|
CreateColumnDefinition,
|
||||||
|
|
@ -47,6 +49,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [tableNameError, setTableNameError] = useState("");
|
const [tableNameError, setTableNameError] = useState("");
|
||||||
const [validationResult, setValidationResult] = useState<any>(null);
|
const [validationResult, setValidationResult] = useState<any>(null);
|
||||||
|
const [useLogTable, setUseLogTable] = useState(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 리셋
|
* 모달 리셋
|
||||||
|
|
@ -65,6 +68,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
]);
|
]);
|
||||||
setTableNameError("");
|
setTableNameError("");
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
|
setUseLogTable(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -204,6 +208,23 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
|
||||||
|
// 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성
|
||||||
|
if (useLogTable) {
|
||||||
|
try {
|
||||||
|
const pkColumn = { columnName: "id", dataType: "integer" };
|
||||||
|
const logResult = await tableManagementApi.createLogTable(tableName, pkColumn);
|
||||||
|
|
||||||
|
if (logResult.success) {
|
||||||
|
toast.success(`${tableName}_log 테이블이 생성되었습니다.`);
|
||||||
|
} else {
|
||||||
|
toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`);
|
||||||
|
}
|
||||||
|
} catch (logError) {
|
||||||
|
toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess(result);
|
onSuccess(result);
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -248,7 +269,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
placeholder="예: customer_info"
|
placeholder="예: customer_info"
|
||||||
className={tableNameError ? "border-red-300" : ""}
|
className={tableNameError ? "border-red-300" : ""}
|
||||||
/>
|
/>
|
||||||
{tableNameError && <p className="text-sm text-destructive">{tableNameError}</p>}
|
{tableNameError && <p className="text-destructive text-sm">{tableNameError}</p>}
|
||||||
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
<p className="text-muted-foreground text-xs">영문자로 시작, 영문자/숫자/언더스코어만 사용 가능</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa
|
||||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 테이블 생성 옵션 */}
|
||||||
|
<div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||||
|
<Checkbox
|
||||||
|
id="useLogTable"
|
||||||
|
checked={useLogTable}
|
||||||
|
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor="useLogTable"
|
||||||
|
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
변경 이력 로그 테이블 생성
|
||||||
|
</label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
선택 시 <code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code> 테이블이
|
||||||
|
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 자동 추가 컬럼 안내 */}
|
{/* 자동 추가 컬럼 안내 */}
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { History, RefreshCw, Filter, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface TableLogViewerProps {
|
||||||
|
tableName: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogData {
|
||||||
|
log_id: number;
|
||||||
|
operation_type: string;
|
||||||
|
original_id: string;
|
||||||
|
changed_column?: string;
|
||||||
|
old_value?: string;
|
||||||
|
new_value?: string;
|
||||||
|
changed_by?: string;
|
||||||
|
changed_at: string;
|
||||||
|
ip_address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableLogViewer({ tableName, open, onOpenChange }: TableLogViewerProps) {
|
||||||
|
const [logs, setLogs] = useState<LogData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize] = useState(20);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
const [operationType, setOperationType] = useState<string>("");
|
||||||
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
|
const [changedBy, setChangedBy] = useState<string>("");
|
||||||
|
const [originalId, setOriginalId] = useState<string>("");
|
||||||
|
|
||||||
|
// 로그 데이터 로드
|
||||||
|
const loadLogs = async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getLogData(tableName, {
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
operationType: operationType || undefined,
|
||||||
|
startDate: startDate || undefined,
|
||||||
|
endDate: endDate || undefined,
|
||||||
|
changedBy: changedBy || undefined,
|
||||||
|
originalId: originalId || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setLogs(response.data.data);
|
||||||
|
setTotal(response.data.total);
|
||||||
|
setTotalPages(response.data.totalPages);
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "로그 데이터를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("로그 데이터 조회 중 오류가 발생했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다이얼로그가 열릴 때 로그 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && tableName) {
|
||||||
|
loadLogs();
|
||||||
|
}
|
||||||
|
}, [open, tableName, page]);
|
||||||
|
|
||||||
|
// 필터 초기화
|
||||||
|
const resetFilters = () => {
|
||||||
|
setOperationType("");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setChangedBy("");
|
||||||
|
setOriginalId("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업 타입에 따른 뱃지 색상
|
||||||
|
const getOperationBadge = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "INSERT":
|
||||||
|
return <Badge className="bg-green-500">추가</Badge>;
|
||||||
|
case "UPDATE":
|
||||||
|
return <Badge className="bg-blue-500">수정</Badge>;
|
||||||
|
case "DELETE":
|
||||||
|
return <Badge className="bg-red-500">삭제</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge>{type}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷팅
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
{tableName} - 변경 이력
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 필터 영역 */}
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
필터
|
||||||
|
</h4>
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-600">작업 유형</label>
|
||||||
|
<Select value={operationType} onValueChange={setOperationType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">전체</SelectItem>
|
||||||
|
<SelectItem value="INSERT">추가</SelectItem>
|
||||||
|
<SelectItem value="UPDATE">수정</SelectItem>
|
||||||
|
<SelectItem value="DELETE">삭제</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-600">시작 날짜</label>
|
||||||
|
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-600">종료 날짜</label>
|
||||||
|
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-600">변경자</label>
|
||||||
|
<Input placeholder="사용자 ID" value={changedBy} onChange={(e) => setChangedBy(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm text-gray-600">원본 ID</label>
|
||||||
|
<Input placeholder="레코드 ID" value={originalId} onChange={(e) => setOriginalId(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={loadLogs} className="w-full">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto rounded-lg border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-500">변경 이력이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">작업</TableHead>
|
||||||
|
<TableHead>원본 ID</TableHead>
|
||||||
|
<TableHead>변경 컬럼</TableHead>
|
||||||
|
<TableHead>변경 전</TableHead>
|
||||||
|
<TableHead>변경 후</TableHead>
|
||||||
|
<TableHead>변경자</TableHead>
|
||||||
|
<TableHead>변경 시각</TableHead>
|
||||||
|
<TableHead>IP</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<TableRow key={log.log_id}>
|
||||||
|
<TableCell>{getOperationBadge(log.operation_type)}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{log.original_id}</TableCell>
|
||||||
|
<TableCell className="text-sm">{log.changed_column || "-"}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-sm" title={log.old_value}>
|
||||||
|
{log.old_value || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate text-sm" title={log.new_value}>
|
||||||
|
{log.new_value || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{log.changed_by || "system"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{formatDate(log.changed_at)}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{log.ip_address || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
전체 {total}건 (페이지 {page} / {totalPages})
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1 || loading}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -105,6 +105,8 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||||
// 기사 관리 위젯 임포트
|
// 기사 관리 위젯 임포트
|
||||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||||
import { ListWidget } from "./widgets/ListWidget";
|
import { ListWidget } from "./widgets/ListWidget";
|
||||||
|
import { MoreHorizontal, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
// 야드 관리 3D 위젯
|
// 야드 관리 3D 위젯
|
||||||
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
||||||
|
|
@ -127,10 +129,17 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
|
||||||
|
allElements?: DashboardElement[]; // 🔥 모든 요소 배열
|
||||||
|
multiDragOffset?: { x: number; y: number }; // 🔥 다중 드래그 시 이 요소의 오프셋
|
||||||
cellSize: number;
|
cellSize: number;
|
||||||
subGridSize: number;
|
subGridSize: number;
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
|
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||||
|
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
|
||||||
|
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
|
||||||
|
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onConfigure?: (element: DashboardElement) => void;
|
onConfigure?: (element: DashboardElement) => void;
|
||||||
|
|
@ -145,10 +154,17 @@ interface CanvasElementProps {
|
||||||
export function CanvasElement({
|
export function CanvasElement({
|
||||||
element,
|
element,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
selectedElements = [],
|
||||||
|
allElements = [],
|
||||||
|
multiDragOffset,
|
||||||
cellSize,
|
cellSize,
|
||||||
subGridSize,
|
subGridSize,
|
||||||
canvasWidth = 1560,
|
canvasWidth = 1560,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragStart,
|
||||||
|
onMultiDragMove,
|
||||||
|
onMultiDragEnd,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelect,
|
onSelect,
|
||||||
onConfigure,
|
onConfigure,
|
||||||
|
|
@ -156,6 +172,10 @@ export function CanvasElement({
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||||||
|
const dragStartRef = useRef({ x: 0, y: 0, elementX: 0, elementY: 0, initialScrollY: 0 }); // 🔥 스크롤 조정용 ref
|
||||||
|
const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향
|
||||||
|
const autoScrollFrameRef = useRef<number | null>(null); // 🔥 requestAnimationFrame ID
|
||||||
|
const lastMouseYRef = useRef<number>(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간)
|
||||||
const [resizeStart, setResizeStart] = useState({
|
const [resizeStart, setResizeStart] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
|
|
@ -197,15 +217,39 @@ export function CanvasElement({
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setDragStart({
|
const startPos = {
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
elementX: element.position.x,
|
elementX: element.position.x,
|
||||||
elementY: element.position.y,
|
elementY: element.position.y,
|
||||||
});
|
initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치
|
||||||
|
};
|
||||||
|
setDragStart(startPos);
|
||||||
|
dragStartRef.current = startPos; // 🔥 ref에도 저장
|
||||||
|
|
||||||
|
// 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간)
|
||||||
|
lastMouseYRef.current = window.innerHeight / 2;
|
||||||
|
|
||||||
|
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
|
||||||
|
const offsets: Record<string, { x: number; y: number }> = {};
|
||||||
|
selectedElements.forEach((id) => {
|
||||||
|
if (id !== element.id) {
|
||||||
|
const targetElement = allElements.find((el) => el.id === id);
|
||||||
|
if (targetElement) {
|
||||||
|
offsets[id] = {
|
||||||
|
x: targetElement.position.x - element.position.x,
|
||||||
|
y: targetElement.position.y - element.position.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMultiDragStart(element.id, offsets);
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
[element.id, element.position.x, element.position.y, onSelect, isSelected],
|
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 핸들 마우스다운
|
// 리사이즈 핸들 마우스다운
|
||||||
|
|
@ -235,8 +279,25 @@ export function CanvasElement({
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
const deltaX = e.clientX - dragStart.x;
|
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
||||||
const deltaY = e.clientY - dragStart.y;
|
const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||||
|
|
||||||
|
if (isFirstSelectedElement) {
|
||||||
|
const scrollThreshold = 100;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const mouseY = e.clientY;
|
||||||
|
|
||||||
|
// 🔥 항상 마우스 위치 업데이트
|
||||||
|
lastMouseYRef.current = mouseY;
|
||||||
|
// console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 현재 스크롤 위치를 고려한 deltaY 계산
|
||||||
|
const currentScrollY = window.pageYOffset;
|
||||||
|
const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY;
|
||||||
|
|
||||||
|
const deltaX = e.clientX - dragStartRef.current.x;
|
||||||
|
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
|
||||||
|
|
||||||
// 임시 위치 계산
|
// 임시 위치 계산
|
||||||
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||||
|
|
@ -246,21 +307,16 @@ export function CanvasElement({
|
||||||
const maxX = canvasWidth - element.size.width;
|
const maxX = canvasWidth - element.size.width;
|
||||||
rawX = Math.min(rawX, maxX);
|
rawX = Math.min(rawX, maxX);
|
||||||
|
|
||||||
// 드래그 중 실시간 스냅 (마그네틱 스냅)
|
// 드래그 중 실시간 스냅 (서브그리드만 사용)
|
||||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
const snappedX = Math.round(rawX / subGridSize) * subGridSize;
|
||||||
const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px)
|
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
|
||||||
|
|
||||||
// X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
|
||||||
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
|
||||||
const distToGridX = Math.abs(rawX - nearestGridX);
|
|
||||||
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
// Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
|
||||||
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
|
|
||||||
const distToGridY = Math.abs(rawY - nearestGridY);
|
|
||||||
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
setTempPosition({ x: snappedX, y: snappedY });
|
setTempPosition({ x: snappedX, y: snappedY });
|
||||||
|
|
||||||
|
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||||
|
onMultiDragMove(element, { x: snappedX, y: snappedY });
|
||||||
|
}
|
||||||
} else if (isResizing) {
|
} else if (isResizing) {
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
const deltaX = e.clientX - resizeStart.x;
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
const deltaY = e.clientY - resizeStart.y;
|
||||||
|
|
@ -303,35 +359,11 @@ export function CanvasElement({
|
||||||
const maxWidth = canvasWidth - newX;
|
const maxWidth = canvasWidth - newX;
|
||||||
newWidth = Math.min(newWidth, maxWidth);
|
newWidth = Math.min(newWidth, maxWidth);
|
||||||
|
|
||||||
// 리사이즈 중 실시간 스냅 (마그네틱 스냅)
|
// 리사이즈 중 실시간 스냅 (서브그리드만 사용)
|
||||||
const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기
|
const snappedX = Math.round(newX / subGridSize) * subGridSize;
|
||||||
const magneticThreshold = 15;
|
const snappedY = Math.round(newY / subGridSize) * subGridSize;
|
||||||
|
const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
|
||||||
// 위치 스냅
|
const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
|
||||||
const nearestGridX = Math.round(newX / gridSize) * gridSize;
|
|
||||||
const distToGridX = Math.abs(newX - nearestGridX);
|
|
||||||
const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
const nearestGridY = Math.round(newY / gridSize) * gridSize;
|
|
||||||
const distToGridY = Math.abs(newY - nearestGridY);
|
|
||||||
const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
// 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외)
|
|
||||||
// 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2
|
|
||||||
const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5;
|
|
||||||
|
|
||||||
// 가장 가까운 그리드 칸 수 계산
|
|
||||||
const nearestWidthCells = Math.round(newWidth / gridSize);
|
|
||||||
const nearestGridWidth = calculateGridWidth(nearestWidthCells);
|
|
||||||
const distToGridWidth = Math.abs(newWidth - nearestGridWidth);
|
|
||||||
const snappedWidth =
|
|
||||||
distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
const nearestHeightCells = Math.round(newHeight / gridSize);
|
|
||||||
const nearestGridHeight = calculateGridWidth(nearestHeightCells);
|
|
||||||
const distToGridHeight = Math.abs(newHeight - nearestGridHeight);
|
|
||||||
const snappedHeight =
|
|
||||||
distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize;
|
|
||||||
|
|
||||||
// 임시 크기/위치 저장 (스냅됨)
|
// 임시 크기/위치 저장 (스냅됨)
|
||||||
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
||||||
|
|
@ -343,12 +375,15 @@ export function CanvasElement({
|
||||||
isResizing,
|
isResizing,
|
||||||
dragStart,
|
dragStart,
|
||||||
resizeStart,
|
resizeStart,
|
||||||
element.size.width,
|
element,
|
||||||
element.type,
|
|
||||||
element.subtype,
|
|
||||||
canvasWidth,
|
canvasWidth,
|
||||||
cellSize,
|
cellSize,
|
||||||
subGridSize,
|
subGridSize,
|
||||||
|
selectedElements,
|
||||||
|
allElements,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragMove,
|
||||||
|
// dragStartRef, autoScrollDirectionRef, autoScrollFrameRef는 ref라서 dependency 불필요
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -368,7 +403,42 @@ export function CanvasElement({
|
||||||
position: { x: finalX, y: finalY },
|
position: { x: finalX, y: finalY },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔥 다중 선택된 요소들도 함께 업데이트
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onUpdateMultiple) {
|
||||||
|
const updates = selectedElements
|
||||||
|
.filter((id) => id !== element.id) // 현재 요소 제외
|
||||||
|
.map((id) => {
|
||||||
|
const targetElement = allElements.find((el) => el.id === id);
|
||||||
|
if (!targetElement) return null;
|
||||||
|
|
||||||
|
// 현재 요소와의 상대적 위치 유지
|
||||||
|
const relativeX = targetElement.position.x - dragStart.elementX;
|
||||||
|
const relativeY = targetElement.position.y - dragStart.elementY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
updates: {
|
||||||
|
position: {
|
||||||
|
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
|
||||||
|
y: Math.max(0, finalY + relativeY),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||||
|
onUpdateMultiple(updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTempPosition(null);
|
setTempPosition(null);
|
||||||
|
|
||||||
|
// 🔥 다중 드래그 종료
|
||||||
|
if (onMultiDragEnd) {
|
||||||
|
onMultiDragEnd();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResizing && tempPosition && tempSize) {
|
if (isResizing && tempPosition && tempSize) {
|
||||||
|
|
@ -394,7 +464,84 @@ export function CanvasElement({
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]);
|
|
||||||
|
// 🔥 자동 스크롤 정리
|
||||||
|
autoScrollDirectionRef.current = null;
|
||||||
|
if (autoScrollFrameRef.current) {
|
||||||
|
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||||
|
autoScrollFrameRef.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isDragging,
|
||||||
|
isResizing,
|
||||||
|
tempPosition,
|
||||||
|
tempSize,
|
||||||
|
element.id,
|
||||||
|
element.size.width,
|
||||||
|
onUpdate,
|
||||||
|
onUpdateMultiple,
|
||||||
|
onMultiDragEnd,
|
||||||
|
cellSize,
|
||||||
|
canvasWidth,
|
||||||
|
selectedElements,
|
||||||
|
allElements,
|
||||||
|
dragStart.elementX,
|
||||||
|
dragStart.elementY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const scrollSpeed = 3; // 🔥 속도를 좀 더 부드럽게 (5 → 3)
|
||||||
|
const scrollThreshold = 100;
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
const autoScrollLoop = (currentTime: number) => {
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const lastMouseY = lastMouseYRef.current;
|
||||||
|
|
||||||
|
// 🔥 스크롤 방향 결정
|
||||||
|
let shouldScroll = false;
|
||||||
|
let scrollDirection = 0;
|
||||||
|
|
||||||
|
if (lastMouseY < scrollThreshold) {
|
||||||
|
// 위쪽 영역
|
||||||
|
shouldScroll = true;
|
||||||
|
scrollDirection = -scrollSpeed;
|
||||||
|
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
|
||||||
|
} else if (lastMouseY > viewportHeight - scrollThreshold) {
|
||||||
|
// 아래쪽 영역
|
||||||
|
shouldScroll = true;
|
||||||
|
scrollDirection = scrollSpeed;
|
||||||
|
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 프레임 간격 계산
|
||||||
|
const deltaTime = currentTime - lastTime;
|
||||||
|
|
||||||
|
// 🔥 10ms 간격으로 스크롤
|
||||||
|
if (shouldScroll && deltaTime >= 10) {
|
||||||
|
window.scrollBy(0, scrollDirection);
|
||||||
|
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
|
||||||
|
lastTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계속 반복
|
||||||
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 루프 시작
|
||||||
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||||
|
autoScrollFrameRef.current = animationFrameId;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isDragging]);
|
||||||
|
|
||||||
// 전역 마우스 이벤트 등록
|
// 전역 마우스 이벤트 등록
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -523,12 +670,17 @@ export function CanvasElement({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||||
const displayPosition = tempPosition || element.position;
|
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
|
||||||
|
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
|
||||||
|
x: element.position.x + multiDragOffset.x,
|
||||||
|
y: element.position.y + multiDragOffset.y,
|
||||||
|
} : element.position);
|
||||||
const displaySize = tempSize || element.size;
|
const displaySize = tempSize || element.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
|
data-element-id={element.id}
|
||||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||||
style={{
|
style={{
|
||||||
left: displayPosition.x,
|
left: displayPosition.x,
|
||||||
|
|
@ -541,27 +693,31 @@ export function CanvasElement({
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
<div className="flex cursor-move items-center justify-between p-3">
|
||||||
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
|
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
|
||||||
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
|
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
|
||||||
<button
|
<Button
|
||||||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-gray-400"
|
||||||
onClick={() => onConfigure(element)}
|
onClick={() => onConfigure(element)}
|
||||||
title="설정"
|
title="설정"
|
||||||
>
|
>
|
||||||
⚙️
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<button
|
<Button
|
||||||
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="element-close hover:bg-destructive h-6 w-6 text-gray-400 hover:text-white"
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
×
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { forwardRef, useState, useCallback, useMemo } from "react";
|
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||||
import { CanvasElement } from "./CanvasElement";
|
import { CanvasElement } from "./CanvasElement";
|
||||||
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
||||||
|
|
@ -9,10 +9,12 @@ import { resolveAllCollisions } from "./collisionUtils";
|
||||||
interface DashboardCanvasProps {
|
interface DashboardCanvasProps {
|
||||||
elements: DashboardElement[];
|
elements: DashboardElement[];
|
||||||
selectedElement: string | null;
|
selectedElement: string | null;
|
||||||
|
selectedElements?: string[]; // 🔥 다중 선택된 요소 ID 배열
|
||||||
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
|
||||||
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
onRemoveElement: (id: string) => void;
|
onRemoveElement: (id: string) => void;
|
||||||
onSelectElement: (id: string | null) => void;
|
onSelectElement: (id: string | null) => void;
|
||||||
|
onSelectMultiple?: (ids: string[]) => void; // 🔥 다중 선택 핸들러
|
||||||
onConfigureElement?: (element: DashboardElement) => void;
|
onConfigureElement?: (element: DashboardElement) => void;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
|
|
@ -31,10 +33,12 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
{
|
{
|
||||||
elements,
|
elements,
|
||||||
selectedElement,
|
selectedElement,
|
||||||
|
selectedElements = [],
|
||||||
onCreateElement,
|
onCreateElement,
|
||||||
onUpdateElement,
|
onUpdateElement,
|
||||||
onRemoveElement,
|
onRemoveElement,
|
||||||
onSelectElement,
|
onSelectElement,
|
||||||
|
onSelectMultiple,
|
||||||
onConfigureElement,
|
onConfigureElement,
|
||||||
backgroundColor = "#f9fafb",
|
backgroundColor = "#f9fafb",
|
||||||
canvasWidth = 1560,
|
canvasWidth = 1560,
|
||||||
|
|
@ -43,6 +47,24 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 상태
|
||||||
|
const [selectionBox, setSelectionBox] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [isSelecting, setIsSelecting] = useState(false);
|
||||||
|
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
|
||||||
|
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
|
||||||
|
|
||||||
|
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
|
||||||
|
const [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
|
||||||
|
|
||||||
|
// 🔥 선택 박스 드래그 중 자동 스크롤
|
||||||
|
const lastMouseYForSelectionRef = React.useRef<number>(window.innerHeight / 2);
|
||||||
|
const selectionAutoScrollFrameRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
// 현재 캔버스 크기에 맞는 그리드 설정 계산
|
// 현재 캔버스 크기에 맞는 그리드 설정 계산
|
||||||
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
||||||
|
|
@ -182,14 +204,230 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
[ref, onCreateElement, canvasWidth, cellSize],
|
[ref, onCreateElement, canvasWidth, cellSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 드래그 시작
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
// 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우)
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isWidget = target.closest("[data-element-id]");
|
||||||
|
|
||||||
|
if (isWidget) {
|
||||||
|
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
|
||||||
|
|
||||||
|
if (!ref || typeof ref === "function") return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||||
|
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||||
|
|
||||||
|
// 🔥 일단 시작 위치만 저장 (아직 isSelecting은 false)
|
||||||
|
setSelectionBox({ startX: x, startY: y, endX: x, endY: y });
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 드래그 종료
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (!isSelecting || !selectionBox) {
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionBox(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onSelectMultiple) {
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionBox(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 박스 영역 계산
|
||||||
|
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||||
|
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||||
|
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||||
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
||||||
|
|
||||||
|
// console.log("🔍 선택 박스:", { minX, maxX, minY, maxY });
|
||||||
|
|
||||||
|
// 선택 박스 안에 있는 요소들 찾기 (70% 이상 겹치면 선택)
|
||||||
|
const selectedIds = elements
|
||||||
|
.filter((el) => {
|
||||||
|
const elLeft = el.position.x;
|
||||||
|
const elRight = el.position.x + el.size.width;
|
||||||
|
const elTop = el.position.y;
|
||||||
|
const elBottom = el.position.y + el.size.height;
|
||||||
|
|
||||||
|
// 겹치는 영역 계산
|
||||||
|
const overlapLeft = Math.max(elLeft, minX);
|
||||||
|
const overlapRight = Math.min(elRight, maxX);
|
||||||
|
const overlapTop = Math.max(elTop, minY);
|
||||||
|
const overlapBottom = Math.min(elBottom, maxY);
|
||||||
|
|
||||||
|
// 겹치는 영역이 없으면 false
|
||||||
|
if (overlapRight < overlapLeft || overlapBottom < overlapTop) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 겹치는 영역의 넓이
|
||||||
|
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
|
||||||
|
|
||||||
|
// 요소의 전체 넓이
|
||||||
|
const elementArea = el.size.width * el.size.height;
|
||||||
|
|
||||||
|
// 70% 이상 겹치면 선택
|
||||||
|
const overlapPercentage = overlapArea / elementArea;
|
||||||
|
|
||||||
|
// console.log(`📦 요소 ${el.id}:`, {
|
||||||
|
// position: el.position,
|
||||||
|
// size: el.size,
|
||||||
|
// overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
|
||||||
|
// selected: overlapPercentage >= 0.7,
|
||||||
|
// });
|
||||||
|
|
||||||
|
return overlapPercentage >= 0.7;
|
||||||
|
})
|
||||||
|
.map((el) => el.id);
|
||||||
|
|
||||||
|
// console.log("✅ 선택된 요소:", selectedIds);
|
||||||
|
|
||||||
|
if (selectedIds.length > 0) {
|
||||||
|
onSelectMultiple(selectedIds);
|
||||||
|
setJustSelected(true); // 🔥 방금 선택했음을 표시
|
||||||
|
setTimeout(() => setJustSelected(false), 100); // 100ms 후 플래그 해제
|
||||||
|
} else {
|
||||||
|
onSelectMultiple([]); // 빈 배열도 전달
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSelecting(false);
|
||||||
|
setSelectionBox(null);
|
||||||
|
}, [isSelecting, selectionBox, elements, onSelectMultiple]);
|
||||||
|
|
||||||
|
// 🔥 document 레벨에서 마우스 이동/해제 감지 (위젯 위에서도 작동)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectionBox) return;
|
||||||
|
|
||||||
|
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!ref || typeof ref === "function") return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||||
|
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||||
|
|
||||||
|
// 🔥 자동 스크롤을 위한 마우스 Y 위치 저장
|
||||||
|
lastMouseYForSelectionRef.current = e.clientY;
|
||||||
|
|
||||||
|
// console.log("🖱️ 마우스 이동:", { x, y, startX: selectionBox.startX, startY: selectionBox.startY, isSelecting });
|
||||||
|
|
||||||
|
// 🔥 selectionBox가 있지만 아직 isSelecting이 false인 경우 (드래그 시작 대기)
|
||||||
|
if (!isSelecting) {
|
||||||
|
const deltaX = Math.abs(x - selectionBox.startX);
|
||||||
|
const deltaY = Math.abs(y - selectionBox.startY);
|
||||||
|
|
||||||
|
// console.log("📏 이동 거리:", { deltaX, deltaY });
|
||||||
|
|
||||||
|
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
|
||||||
|
if (deltaX > 5 || deltaY > 5) {
|
||||||
|
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
|
||||||
|
setIsSelecting(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 선택 박스 업데이트
|
||||||
|
// console.log("📦 선택 박스 업데이트:", { startX: selectionBox.startX, startY: selectionBox.startY, endX: x, endY: y });
|
||||||
|
setSelectionBox((prev) => (prev ? { ...prev, endX: x, endY: y } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentMouseUp = () => {
|
||||||
|
// console.log("🖱️ 마우스 업 - handleMouseUp 호출");
|
||||||
|
handleMouseUp();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||||
|
};
|
||||||
|
}, [selectionBox, isSelecting, ref, handleMouseUp]);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 드래그 중 자동 스크롤
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSelecting) {
|
||||||
|
// console.log("❌ 자동 스크롤 비활성화: isSelecting =", isSelecting);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("✅ 자동 스크롤 활성화: isSelecting =", isSelecting);
|
||||||
|
|
||||||
|
const scrollSpeed = 3;
|
||||||
|
const scrollThreshold = 100;
|
||||||
|
let animationFrameId: number;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
|
||||||
|
const autoScrollLoop = (currentTime: number) => {
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const lastMouseY = lastMouseYForSelectionRef.current;
|
||||||
|
|
||||||
|
let shouldScroll = false;
|
||||||
|
let scrollDirection = 0;
|
||||||
|
|
||||||
|
if (lastMouseY < scrollThreshold) {
|
||||||
|
shouldScroll = true;
|
||||||
|
scrollDirection = -scrollSpeed;
|
||||||
|
// console.log("⬆️ 위로 스크롤 (선택 박스):", { lastMouseY, scrollThreshold });
|
||||||
|
} else if (lastMouseY > viewportHeight - scrollThreshold) {
|
||||||
|
shouldScroll = true;
|
||||||
|
scrollDirection = scrollSpeed;
|
||||||
|
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaTime = currentTime - lastTime;
|
||||||
|
|
||||||
|
if (shouldScroll && deltaTime >= 10) {
|
||||||
|
window.scrollBy(0, scrollDirection);
|
||||||
|
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
|
||||||
|
lastTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||||
|
selectionAutoScrollFrameRef.current = animationFrameId;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameId) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
// console.log("🛑 자동 스크롤 정리");
|
||||||
|
};
|
||||||
|
}, [isSelecting]);
|
||||||
|
|
||||||
// 캔버스 클릭 시 선택 해제
|
// 캔버스 클릭 시 선택 해제
|
||||||
const handleCanvasClick = useCallback(
|
const handleCanvasClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
// 🔥 방금 선택했거나 드래그 중이면 클릭 이벤트 무시 (선택 해제 방지)
|
||||||
|
if (justSelected || isDraggingAny) {
|
||||||
|
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
|
// console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||||
onSelectElement(null);
|
onSelectElement(null);
|
||||||
|
if (onSelectMultiple) {
|
||||||
|
onSelectMultiple([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSelectElement],
|
[onSelectElement, onSelectMultiple, justSelected, isDraggingAny],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 동적 그리드 크기 계산
|
// 동적 그리드 크기 계산
|
||||||
|
|
@ -202,6 +440,23 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
// 12개 컬럼 구분선 위치 계산
|
// 12개 컬럼 구분선 위치 계산
|
||||||
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
||||||
|
|
||||||
|
// 🔥 선택 박스 스타일 계산
|
||||||
|
const selectionBoxStyle = useMemo(() => {
|
||||||
|
if (!selectionBox) return null;
|
||||||
|
|
||||||
|
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||||
|
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||||
|
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||||
|
const maxY = Math.max(selectionBox.startY, selectionBox.endY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${minX}px`,
|
||||||
|
top: `${minY}px`,
|
||||||
|
width: `${maxX - minX}px`,
|
||||||
|
height: `${maxY - minY}px`,
|
||||||
|
};
|
||||||
|
}, [selectionBox]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -218,14 +473,16 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
||||||
backgroundPosition: "0 0",
|
backgroundPosition: "0 0",
|
||||||
backgroundRepeat: "repeat",
|
backgroundRepeat: "repeat",
|
||||||
|
cursor: isSelecting ? "crosshair" : "default",
|
||||||
}}
|
}}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{/* 12개 컬럼 메인 구분선 */}
|
{/* 12개 컬럼 메인 구분선 - 주석 처리 (서브그리드만 사용) */}
|
||||||
{columnLines.map((x, i) => (
|
{/* {columnLines.map((x, i) => (
|
||||||
<div
|
<div
|
||||||
key={`col-${i}`}
|
key={`col-${i}`}
|
||||||
className="pointer-events-none absolute top-0 h-full"
|
className="pointer-events-none absolute top-0 h-full"
|
||||||
|
|
@ -236,7 +493,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))} */}
|
||||||
{/* 배치된 요소들 렌더링 */}
|
{/* 배치된 요소들 렌더링 */}
|
||||||
{elements.length === 0 && (
|
{elements.length === 0 && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
||||||
|
|
@ -249,16 +506,68 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
<CanvasElement
|
<CanvasElement
|
||||||
key={element.id}
|
key={element.id}
|
||||||
element={element}
|
element={element}
|
||||||
isSelected={selectedElement === element.id}
|
isSelected={selectedElement === element.id || selectedElements.includes(element.id)}
|
||||||
|
selectedElements={selectedElements}
|
||||||
|
allElements={elements}
|
||||||
|
multiDragOffset={multiDragOffsets[element.id]}
|
||||||
cellSize={cellSize}
|
cellSize={cellSize}
|
||||||
subGridSize={subGridSize}
|
subGridSize={subGridSize}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
onUpdate={handleUpdateWithCollisionDetection}
|
onUpdate={handleUpdateWithCollisionDetection}
|
||||||
|
onUpdateMultiple={(updates) => {
|
||||||
|
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
|
||||||
|
updates.forEach(({ id, updates: elementUpdates }) => {
|
||||||
|
onUpdateElement(id, elementUpdates);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMultiDragStart={(draggedId, initialOffsets) => {
|
||||||
|
// 🔥 다중 드래그 시작 - 초기 오프셋 저장
|
||||||
|
setMultiDragOffsets(initialOffsets);
|
||||||
|
setIsDraggingAny(true);
|
||||||
|
}}
|
||||||
|
onMultiDragMove={(draggedElement, tempPosition) => {
|
||||||
|
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 실시간 업데이트
|
||||||
|
if (selectedElements.length > 1 && selectedElements.includes(draggedElement.id)) {
|
||||||
|
const newOffsets: Record<string, { x: number; y: number }> = {};
|
||||||
|
selectedElements.forEach((id) => {
|
||||||
|
if (id !== draggedElement.id) {
|
||||||
|
const targetElement = elements.find((el) => el.id === id);
|
||||||
|
if (targetElement) {
|
||||||
|
const relativeX = targetElement.position.x - draggedElement.position.x;
|
||||||
|
const relativeY = targetElement.position.y - draggedElement.position.y;
|
||||||
|
newOffsets[id] = {
|
||||||
|
x: tempPosition.x + relativeX - targetElement.position.x,
|
||||||
|
y: tempPosition.y + relativeY - targetElement.position.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMultiDragOffsets(newOffsets);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMultiDragEnd={() => {
|
||||||
|
// 🔥 다중 드래그 종료 - 오프셋 초기화
|
||||||
|
setMultiDragOffsets({});
|
||||||
|
setIsDraggingAny(false);
|
||||||
|
}}
|
||||||
onRemove={onRemoveElement}
|
onRemove={onRemoveElement}
|
||||||
onSelect={onSelectElement}
|
onSelect={onSelectElement}
|
||||||
onConfigure={onConfigureElement}
|
onConfigure={onConfigureElement}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 🔥 선택 박스 렌더링 */}
|
||||||
|
{selectionBox && selectionBoxStyle && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
...selectionBoxStyle,
|
||||||
|
border: "2px dashed #3b82f6",
|
||||||
|
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||||
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
|
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||||
import { useMenu } from "@/contexts/MenuContext";
|
import { useMenu } from "@/contexts/MenuContext";
|
||||||
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -43,6 +44,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||||
|
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
|
||||||
const [elementCounter, setElementCounter] = useState(0);
|
const [elementCounter, setElementCounter] = useState(0);
|
||||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||||
|
|
@ -57,6 +59,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
// 클립보드 (복사/붙여넣기용)
|
||||||
|
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
|
||||||
|
|
||||||
// 화면 해상도 자동 감지
|
// 화면 해상도 자동 감지
|
||||||
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
||||||
const [resolution, setResolution] = useState<Resolution>(screenResolution);
|
const [resolution, setResolution] = useState<Resolution>(screenResolution);
|
||||||
|
|
@ -209,22 +214,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 크기 설정
|
// 기본 크기 설정 (서브그리드 기준)
|
||||||
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
|
const gridConfig = calculateGridConfig(canvasConfig.width);
|
||||||
|
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||||
|
|
||||||
|
// 서브그리드 기준 기본 크기 (픽셀)
|
||||||
|
let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||||
|
let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||||
|
|
||||||
if (type === "chart") {
|
if (type === "chart") {
|
||||||
defaultCells = { width: 4, height: 3 }; // 차트
|
defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸
|
||||||
|
defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸
|
||||||
} else if (type === "widget" && subtype === "calendar") {
|
} else if (type === "widget" && subtype === "calendar") {
|
||||||
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
|
defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸
|
||||||
|
defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 해상도에 맞는 셀 크기 계산
|
|
||||||
const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
|
|
||||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
|
||||||
|
|
||||||
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
|
||||||
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
|
||||||
|
|
||||||
// 크기 유효성 검사
|
// 크기 유효성 검사
|
||||||
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
||||||
// console.error("Invalid size calculated:", {
|
// console.error("Invalid size calculated:", {
|
||||||
|
|
@ -289,6 +294,51 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
[selectedElement],
|
[selectedElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 키보드 단축키 핸들러들
|
||||||
|
const handleCopyElement = useCallback(() => {
|
||||||
|
if (!selectedElement) return;
|
||||||
|
const element = elements.find((el) => el.id === selectedElement);
|
||||||
|
if (element) {
|
||||||
|
setClipboard(element);
|
||||||
|
}
|
||||||
|
}, [selectedElement, elements]);
|
||||||
|
|
||||||
|
const handlePasteElement = useCallback(() => {
|
||||||
|
if (!clipboard) return;
|
||||||
|
|
||||||
|
// 새 ID 생성
|
||||||
|
const newId = `element-${elementCounter + 1}`;
|
||||||
|
setElementCounter((prev) => prev + 1);
|
||||||
|
|
||||||
|
// 위치를 약간 오프셋 (오른쪽 아래로 20px씩)
|
||||||
|
const newElement: DashboardElement = {
|
||||||
|
...clipboard,
|
||||||
|
id: newId,
|
||||||
|
position: {
|
||||||
|
x: clipboard.position.x + 20,
|
||||||
|
y: clipboard.position.y + 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setElements((prev) => [...prev, newElement]);
|
||||||
|
setSelectedElement(newId);
|
||||||
|
}, [clipboard, elementCounter]);
|
||||||
|
|
||||||
|
const handleDeleteSelected = useCallback(() => {
|
||||||
|
if (selectedElement) {
|
||||||
|
removeElement(selectedElement);
|
||||||
|
}
|
||||||
|
}, [selectedElement, removeElement]);
|
||||||
|
|
||||||
|
// 키보드 단축키 활성화
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
selectedElementId: selectedElement,
|
||||||
|
onDelete: handleDeleteSelected,
|
||||||
|
onCopy: handleCopyElement,
|
||||||
|
onPaste: handlePasteElement,
|
||||||
|
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen,
|
||||||
|
});
|
||||||
|
|
||||||
// 전체 삭제 확인 모달 열기
|
// 전체 삭제 확인 모달 열기
|
||||||
const clearCanvas = useCallback(() => {
|
const clearCanvas = useCallback(() => {
|
||||||
setClearConfirmOpen(true);
|
setClearConfirmOpen(true);
|
||||||
|
|
@ -504,10 +554,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
selectedElement={selectedElement}
|
selectedElement={selectedElement}
|
||||||
|
selectedElements={selectedElements}
|
||||||
onCreateElement={createElement}
|
onCreateElement={createElement}
|
||||||
onUpdateElement={updateElement}
|
onUpdateElement={updateElement}
|
||||||
onRemoveElement={removeElement}
|
onRemoveElement={removeElement}
|
||||||
onSelectElement={setSelectedElement}
|
onSelectElement={(id) => {
|
||||||
|
setSelectedElement(id);
|
||||||
|
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
|
||||||
|
}}
|
||||||
|
onSelectMultiple={(ids) => {
|
||||||
|
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
|
||||||
|
setSelectedElements(ids);
|
||||||
|
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
|
||||||
|
}}
|
||||||
onConfigureElement={openConfigModal}
|
onConfigureElement={openConfigModal}
|
||||||
backgroundColor={canvasBackgroundColor}
|
backgroundColor={canvasBackgroundColor}
|
||||||
canvasWidth={canvasConfig.width}
|
canvasWidth={canvasConfig.width}
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
element.subtype === "driver-management" ||
|
element.subtype === "driver-management" ||
|
||||||
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요)
|
||||||
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
|
element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요)
|
||||||
|
|
||||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||||
const isSelfContainedWidget =
|
const isSelfContainedWidget =
|
||||||
element.subtype === "weather" || // 날씨 위젯 (외부 API)
|
element.subtype === "weather" || // 날씨 위젯 (외부 API)
|
||||||
element.subtype === "exchange" || // 환율 위젯 (외부 API)
|
element.subtype === "exchange" || // 환율 위젯 (외부 API)
|
||||||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||||
|
|
@ -150,11 +150,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
|
// 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능
|
||||||
const isHeaderOnlyWidget =
|
const isHeaderOnlyWidget =
|
||||||
element.type === "widget" &&
|
element.type === "widget" &&
|
||||||
(element.subtype === "clock" ||
|
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||||
element.subtype === "calendar" ||
|
|
||||||
isSelfContainedWidget);
|
|
||||||
|
|
||||||
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
// 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||||
if (element.type === "widget" && element.subtype === "driver-management") {
|
if (element.type === "widget" && element.subtype === "driver-management") {
|
||||||
|
|
@ -172,7 +170,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
|
|
||||||
// customTitle이 변경되었는지 확인
|
// customTitle이 변경되었는지 확인
|
||||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||||
|
|
||||||
// showHeader가 변경되었는지 확인
|
// showHeader가 변경되었는지 확인
|
||||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
||||||
|
|
||||||
|
|
@ -214,13 +212,6 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
|
||||||
{isSimpleWidget
|
|
||||||
? "데이터 소스를 설정하세요"
|
|
||||||
: currentStep === 1
|
|
||||||
? "데이터 소스를 선택하세요"
|
|
||||||
: "쿼리를 실행하고 차트를 설정하세요"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
|
|
@ -241,7 +232,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
|
placeholder="예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)"
|
||||||
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
|
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")</p>
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 헤더 표시 옵션 */}
|
{/* 헤더 표시 옵션 */}
|
||||||
|
|
@ -251,7 +244,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
id="showHeader"
|
id="showHeader"
|
||||||
checked={showHeader}
|
checked={showHeader}
|
||||||
onChange={(e) => setShowHeader(e.target.checked)}
|
onChange={(e) => setShowHeader(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
|
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
|
||||||
위젯 헤더 표시 (제목 + 새로고침 버튼)
|
위젯 헤더 표시 (제목 + 새로고침 버튼)
|
||||||
|
|
@ -278,61 +271,65 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
|
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
|
||||||
{/* 왼쪽: 데이터 설정 */}
|
{/* 왼쪽: 데이터 설정 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{dataSource.type === "database" ? (
|
{dataSource.type === "database" ? (
|
||||||
<>
|
<>
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||||
<QueryEditor
|
<QueryEditor
|
||||||
|
dataSource={dataSource}
|
||||||
|
onDataSourceChange={handleDataSourceUpdate}
|
||||||
|
onQueryTest={handleQueryTest}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ApiConfig
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
onChange={handleDataSourceUpdate}
|
||||||
onQueryTest={handleQueryTest}
|
onTestResult={handleQueryTest}
|
||||||
/>
|
/>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 설정 패널 */}
|
{/* 오른쪽: 설정 패널 */}
|
||||||
{!isSimpleWidget && (
|
{!isSimpleWidget && (
|
||||||
<div>
|
<div>
|
||||||
{isMapWidget ? (
|
{isMapWidget ? (
|
||||||
// 지도 위젯: 위도/경도 매핑 패널
|
// 지도 위젯: 위도/경도 매핑 패널
|
||||||
|
queryResult && queryResult.rows.length > 0 ? (
|
||||||
|
<VehicleMapConfigPanel
|
||||||
|
config={chartConfig}
|
||||||
|
queryResult={queryResult}
|
||||||
|
onConfigChange={handleChartConfigChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 지도 설정이 표시됩니다</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : // 차트: 차트 설정 패널
|
||||||
queryResult && queryResult.rows.length > 0 ? (
|
queryResult && queryResult.rows.length > 0 ? (
|
||||||
<VehicleMapConfigPanel
|
<ChartConfigPanel
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
queryResult={queryResult}
|
queryResult={queryResult}
|
||||||
onConfigChange={handleChartConfigChange}
|
onConfigChange={handleChartConfigChange}
|
||||||
|
chartType={element.subtype}
|
||||||
|
dataSourceType={dataSource.type}
|
||||||
|
query={dataSource.query}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 지도 설정이 표시됩니다</div>
|
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
) : // 차트: 차트 설정 패널
|
</div>
|
||||||
queryResult && queryResult.rows.length > 0 ? (
|
)}
|
||||||
<ChartConfigPanel
|
</div>
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
query={dataSource.query}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -376,4 +373,3 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
if (element.dataSource.queryParams) {
|
if (element.dataSource.queryParams) {
|
||||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||||
if (key && value) {
|
if (key && value) {
|
||||||
params.append(key, value);
|
params.append(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -158,11 +158,15 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
const interval = setInterval(fetchData, refreshInterval);
|
const interval = setInterval(fetchData, refreshInterval);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
element.dataSource?.query,
|
element.dataSource?.query,
|
||||||
element.dataSource?.connectionType,
|
element.dataSource?.connectionType,
|
||||||
element.dataSource?.externalConnectionId,
|
element.dataSource?.externalConnectionId,
|
||||||
element.dataSource?.refreshInterval,
|
element.dataSource?.refreshInterval,
|
||||||
|
element.dataSource?.type,
|
||||||
|
element.dataSource?.endpoint,
|
||||||
|
element.dataSource?.jsonPath,
|
||||||
element.chartConfig,
|
element.chartConfig,
|
||||||
data,
|
data,
|
||||||
]);
|
]);
|
||||||
|
|
@ -201,9 +205,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-2xl">📊</div>
|
|
||||||
<div className="text-sm">데이터를 설정해주세요</div>
|
<div className="text-sm">데이터를 설정해주세요</div>
|
||||||
<div className="mt-1 text-xs">⚙️ 버튼을 클릭하여 설정</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, X, Play, AlertCircle } from "lucide-react";
|
import { Plus, X, Play, AlertCircle } from "lucide-react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||||
|
|
||||||
interface ApiConfigProps {
|
interface ApiConfigProps {
|
||||||
dataSource: ChartDataSource;
|
dataSource: ChartDataSource;
|
||||||
|
|
@ -24,6 +26,106 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||||
|
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||||
|
|
||||||
|
// 외부 API 커넥션 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadApiConnections = async () => {
|
||||||
|
const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" });
|
||||||
|
setApiConnections(connections);
|
||||||
|
};
|
||||||
|
loadApiConnections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 외부 커넥션 선택 핸들러
|
||||||
|
const handleConnectionSelect = async (connectionId: string) => {
|
||||||
|
setSelectedConnectionId(connectionId);
|
||||||
|
|
||||||
|
if (!connectionId || connectionId === "manual") return;
|
||||||
|
|
||||||
|
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId));
|
||||||
|
if (!connection) {
|
||||||
|
console.error("커넥션을 찾을 수 없습니다:", connectionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("불러온 커넥션:", connection);
|
||||||
|
|
||||||
|
// 커넥션 설정을 API 설정에 자동 적용
|
||||||
|
const updates: Partial<ChartDataSource> = {
|
||||||
|
endpoint: connection.base_url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: KeyValuePair[] = [];
|
||||||
|
const queryParams: KeyValuePair[] = [];
|
||||||
|
|
||||||
|
// 기본 헤더가 있으면 적용
|
||||||
|
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||||
|
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||||
|
headers.push({
|
||||||
|
id: `header_${Date.now()}_${Math.random()}`,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log("기본 헤더 적용:", headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||||
|
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
||||||
|
console.log("인증 설정:", connection.auth_type, connection.auth_config);
|
||||||
|
|
||||||
|
if (connection.auth_type === "bearer" && connection.auth_config.token) {
|
||||||
|
headers.push({
|
||||||
|
id: `header_${Date.now()}_auth`,
|
||||||
|
key: "Authorization",
|
||||||
|
value: `Bearer ${connection.auth_config.token}`,
|
||||||
|
});
|
||||||
|
console.log("Bearer 토큰 추가");
|
||||||
|
} else if (connection.auth_type === "api-key") {
|
||||||
|
console.log("API Key 설정:", connection.auth_config);
|
||||||
|
|
||||||
|
if (connection.auth_config.keyName && connection.auth_config.keyValue) {
|
||||||
|
if (connection.auth_config.keyLocation === "header") {
|
||||||
|
headers.push({
|
||||||
|
id: `header_${Date.now()}_apikey`,
|
||||||
|
key: connection.auth_config.keyName,
|
||||||
|
value: connection.auth_config.keyValue,
|
||||||
|
});
|
||||||
|
console.log(`API Key 헤더 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`);
|
||||||
|
} else if (connection.auth_config.keyLocation === "query") {
|
||||||
|
queryParams.push({
|
||||||
|
id: `param_${Date.now()}_apikey`,
|
||||||
|
key: connection.auth_config.keyName,
|
||||||
|
value: connection.auth_config.keyValue,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`API Key 쿼리 파라미터 추가: ${connection.auth_config.keyName}=${connection.auth_config.keyValue}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
connection.auth_type === "basic" &&
|
||||||
|
connection.auth_config.username &&
|
||||||
|
connection.auth_config.password
|
||||||
|
) {
|
||||||
|
const basicAuth = btoa(`${connection.auth_config.username}:${connection.auth_config.password}`);
|
||||||
|
headers.push({
|
||||||
|
id: `header_${Date.now()}_basic`,
|
||||||
|
key: "Authorization",
|
||||||
|
value: `Basic ${basicAuth}`,
|
||||||
|
});
|
||||||
|
console.log("Basic Auth 추가");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.headers = headers;
|
||||||
|
updates.queryParams = queryParams;
|
||||||
|
console.log("최종 업데이트:", updates);
|
||||||
|
|
||||||
|
onChange(updates);
|
||||||
|
};
|
||||||
|
|
||||||
// 헤더를 배열로 정규화 (객체 형식 호환)
|
// 헤더를 배열로 정규화 (객체 형식 호환)
|
||||||
const normalizeHeaders = (): KeyValuePair[] => {
|
const normalizeHeaders = (): KeyValuePair[] => {
|
||||||
|
|
@ -217,6 +319,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 커넥션 선택 */}
|
||||||
|
{apiConnections.length > 0 && (
|
||||||
|
<Card className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||||
|
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||||
|
<SelectTrigger className="mt-2">
|
||||||
|
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[9999]">
|
||||||
|
<SelectItem value="manual">직접 입력</SelectItem>
|
||||||
|
{apiConnections.map((conn) => (
|
||||||
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
|
{conn.connection_name}
|
||||||
|
{conn.description && <span className="ml-2 text-xs text-gray-500">({conn.description})</span>}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">외부 커넥션 관리에서 저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* API URL */}
|
{/* API URL */}
|
||||||
<Card className="space-y-4 p-4">
|
<Card className="space-y-4 p-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -230,13 +356,6 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HTTP 메서드 (고정) */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-sm font-medium text-gray-700">HTTP 메서드</Label>
|
|
||||||
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET (고정)</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">데이터 조회는 GET 메서드만 지원합니다</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 쿼리 파라미터 */}
|
{/* 쿼리 파라미터 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
interface KeyboardShortcutsProps {
|
||||||
|
selectedElementId: string | null;
|
||||||
|
onDelete: () => void;
|
||||||
|
onCopy: () => void;
|
||||||
|
onPaste: () => void;
|
||||||
|
onUndo?: () => void;
|
||||||
|
onRedo?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 키보드 단축키 훅
|
||||||
|
*
|
||||||
|
* 지원 단축키:
|
||||||
|
* - Delete: 선택한 요소 삭제
|
||||||
|
* - Ctrl+C: 요소 복사
|
||||||
|
* - Ctrl+V: 요소 붙여넣기
|
||||||
|
* - Ctrl+Z: 실행 취소 (구현 예정)
|
||||||
|
* - Ctrl+Shift+Z: 재실행 (구현 예정)
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts({
|
||||||
|
selectedElementId,
|
||||||
|
onDelete,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
enabled = true,
|
||||||
|
}: KeyboardShortcutsProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// 입력 필드에서는 단축키 비활성화
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.contentEditable === "true" ||
|
||||||
|
target.closest('[role="dialog"]') ||
|
||||||
|
target.closest('[role="alertdialog"]')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||||
|
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
// Delete: 선택한 요소 삭제
|
||||||
|
if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
|
if (selectedElementId) {
|
||||||
|
e.preventDefault();
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C: 복사
|
||||||
|
if (ctrlKey && e.key === "c") {
|
||||||
|
if (selectedElementId) {
|
||||||
|
e.preventDefault();
|
||||||
|
onCopy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V: 붙여넣기
|
||||||
|
if (ctrlKey && e.key === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
onPaste();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z: 실행 취소
|
||||||
|
if (ctrlKey && e.key === "z" && !e.shiftKey) {
|
||||||
|
if (onUndo) {
|
||||||
|
e.preventDefault();
|
||||||
|
onUndo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+Z 또는 Ctrl+Y: 재실행
|
||||||
|
if ((ctrlKey && e.shiftKey && e.key === "z") || (ctrlKey && e.key === "y")) {
|
||||||
|
if (onRedo) {
|
||||||
|
e.preventDefault();
|
||||||
|
onRedo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, selectedElementId, onDelete, onCopy, onPaste, onUndo, onRedo],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown, enabled]);
|
||||||
|
}
|
||||||
|
|
@ -44,9 +44,9 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
|
||||||
<Label className="text-sm font-semibold">시계 스타일</Label>
|
<Label className="text-sm font-semibold">시계 스타일</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{[
|
{[
|
||||||
{ value: "digital", label: "디지털", icon: "🔢" },
|
{ value: "digital", label: "디지털" },
|
||||||
{ value: "analog", label: "아날로그", icon: "🕐" },
|
{ value: "analog", label: "아날로그" },
|
||||||
{ value: "both", label: "둘 다", icon: "⏰" },
|
{ value: "both", label: "둘 다" },
|
||||||
].map((style) => (
|
].map((style) => (
|
||||||
<Button
|
<Button
|
||||||
key={style.value}
|
key={style.value}
|
||||||
|
|
@ -56,7 +56,6 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
|
||||||
className="flex h-auto flex-col items-center gap-1 py-3"
|
className="flex h-auto flex-col items-center gap-1 py-3"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<span className="text-2xl">{style.icon}</span>
|
|
||||||
<span className="text-xs">{style.label}</span>
|
<span className="text-xs">{style.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import * as THREE from "three";
|
||||||
|
|
||||||
interface YardPlacement {
|
interface YardPlacement {
|
||||||
id: number;
|
id: number;
|
||||||
|
yard_layout_id?: number;
|
||||||
material_code?: string | null;
|
material_code?: string | null;
|
||||||
material_name?: string | null;
|
material_name?: string | null;
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
|
|
@ -26,7 +27,7 @@ interface YardPlacement {
|
||||||
interface Yard3DCanvasProps {
|
interface Yard3DCanvasProps {
|
||||||
placements: YardPlacement[];
|
placements: YardPlacement[];
|
||||||
selectedPlacementId: number | null;
|
selectedPlacementId: number | null;
|
||||||
onPlacementClick: (placement: YardPlacement) => void;
|
onPlacementClick: (placement: YardPlacement | null) => void;
|
||||||
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface YardPlacement {
|
interface YardPlacement {
|
||||||
id: number;
|
id: number;
|
||||||
yard_layout_id: number;
|
yard_layout_id?: number;
|
||||||
material_code?: string | null;
|
material_code?: string | null;
|
||||||
material_name?: string | null;
|
material_name?: string | null;
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
|
|
@ -20,12 +20,20 @@ interface YardPlacement {
|
||||||
size_z: number;
|
size_z: number;
|
||||||
color: string;
|
color: string;
|
||||||
data_source_type?: string | null;
|
data_source_type?: string | null;
|
||||||
data_source_config?: any;
|
data_source_config?: Record<string, unknown> | null;
|
||||||
data_binding?: any;
|
data_binding?: Record<string, unknown> | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
memo?: string;
|
memo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface YardLayout {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Yard3DViewerProps {
|
interface Yard3DViewerProps {
|
||||||
layoutId: number;
|
layoutId: number;
|
||||||
}
|
}
|
||||||
|
|
@ -58,13 +66,14 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
||||||
// 야드 레이아웃 정보 조회
|
// 야드 레이아웃 정보 조회
|
||||||
const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
|
const layoutResponse = await yardLayoutApi.getLayoutById(layoutId);
|
||||||
if (layoutResponse.success) {
|
if (layoutResponse.success) {
|
||||||
setLayoutName(layoutResponse.data.name);
|
const layout = layoutResponse.data as YardLayout;
|
||||||
|
setLayoutName(layout.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치 데이터 조회
|
// 배치 데이터 조회
|
||||||
const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
|
const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId);
|
||||||
if (placementsResponse.success) {
|
if (placementsResponse.success) {
|
||||||
setPlacements(placementsResponse.data);
|
setPlacements(placementsResponse.data as YardPlacement[]);
|
||||||
} else {
|
} else {
|
||||||
setError("배치 데이터를 불러올 수 없습니다.");
|
setError("배치 데이터를 불러올 수 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +132,7 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) {
|
||||||
|
|
||||||
{/* 야드 이름 (좌측 상단) */}
|
{/* 야드 이름 (좌측 상단) */}
|
||||||
{layoutName && (
|
{layoutName && (
|
||||||
<div className="absolute top-4 left-4 z-50 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
|
<div className="absolute top-4 left-4 z-49 rounded-lg border border-gray-300 bg-white px-4 py-2 shadow-lg">
|
||||||
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
|
<h2 className="text-base font-bold text-gray-900">{layoutName}</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,11 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(new Set());
|
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 데이터 로드 (백엔드 통합 호출)
|
// 데이터 로드 (백엔드 캐시 조회)
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
// 백엔드 API 호출 (교통사고, 기상특보, 도로공사 통합)
|
// 백엔드 API 호출 (캐시된 데이터)
|
||||||
const response = await apiClient.get<{
|
const response = await apiClient.get<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: Alert[];
|
data: Alert[];
|
||||||
|
|
@ -79,6 +79,48 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 강제 새로고침 (실시간 API 호출)
|
||||||
|
const forceRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
// 강제 갱신 API 호출 (실시간 데이터)
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: Alert[];
|
||||||
|
count: number;
|
||||||
|
message?: string;
|
||||||
|
}>("/risk-alerts/refresh", {});
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const newData = response.data.data;
|
||||||
|
|
||||||
|
// 새로운 알림 감지
|
||||||
|
const oldIds = new Set(alerts.map(a => a.id));
|
||||||
|
const newIds = new Set<string>();
|
||||||
|
newData.forEach(alert => {
|
||||||
|
if (!oldIds.has(alert.id)) {
|
||||||
|
newIds.add(alert.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setAlerts(newData);
|
||||||
|
setNewAlertIds(newIds);
|
||||||
|
setLastUpdated(new Date());
|
||||||
|
|
||||||
|
// 3초 후 새 알림 애니메이션 제거
|
||||||
|
if (newIds.size > 0) {
|
||||||
|
setTimeout(() => setNewAlertIds(new Set()), 3000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("❌ 리스크 알림 강제 갱신 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 리스크 알림 강제 갱신 오류:", error.message);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
// 1분마다 자동 새로고침 (60000ms)
|
// 1분마다 자동 새로고침 (60000ms)
|
||||||
|
|
@ -156,7 +198,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) {
|
||||||
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing}>
|
<Button variant="ghost" size="sm" onClick={forceRefresh} disabled={isRefreshing} title="실시간 데이터 갱신">
|
||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1">
|
<div className="flex flex-1 pt-14">
|
||||||
{/* 모바일 사이드바 오버레이 */}
|
{/* 모바일 사이드바 오버레이 */}
|
||||||
{sidebarOpen && isMobile && (
|
{sidebarOpen && isMobile && (
|
||||||
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
|
|
@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
isMobile
|
isMobile
|
||||||
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
|
||||||
: "relative top-0 z-auto translate-x-0"
|
: "relative top-0 z-auto translate-x-0"
|
||||||
} flex h-full w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
} flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
|
||||||
>
|
>
|
||||||
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
|
||||||
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
{(user as ExtendedUserInfo)?.userType === "admin" && (
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface MainHeaderProps {
|
||||||
*/
|
*/
|
||||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-background/95 sticky top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
||||||
<div className="flex h-full w-full items-center justify-between px-6">
|
<div className="flex h-full w-full items-center justify-between px-6">
|
||||||
{/* Left side - Side Menu + Logo */}
|
{/* Left side - Side Menu + Logo */}
|
||||||
<div className="flex h-8 items-center gap-2">
|
<div className="flex h-8 items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, createContext, useContext } from "react";
|
import React, { useState, createContext, useContext } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Monitor, Tablet, Smartphone, X } from "lucide-react";
|
import { Monitor, Tablet, Smartphone } from "lucide-react";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine";
|
import { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine";
|
||||||
import { Breakpoint } from "@/types/responsive";
|
import { Breakpoint } from "@/types/responsive";
|
||||||
|
|
@ -76,12 +76,7 @@ export const ResponsivePreviewModal: React.FC<ResponsivePreviewModalProps> = ({
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
|
<DialogContent className="max-h-[95vh] max-w-[95vw] p-0">
|
||||||
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
<DialogHeader className="border-b px-6 pt-6 pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<DialogTitle>반응형 미리보기</DialogTitle>
|
||||||
<DialogTitle>반응형 미리보기</DialogTitle>
|
|
||||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 디바이스 선택 버튼들 */}
|
{/* 디바이스 선택 버튼들 */}
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,625 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
|
|
||||||
|
interface ButtonConfigPanelProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
// 🔧 항상 최신 component에서 직접 참조
|
||||||
|
const config = component.componentConfig || {};
|
||||||
|
const currentAction = component.componentConfig?.action || {}; // 🔧 최신 action 참조
|
||||||
|
|
||||||
|
// 로컬 상태 관리 (실시간 입력 반영)
|
||||||
|
const [localInputs, setLocalInputs] = useState({
|
||||||
|
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
|
||||||
|
modalTitle: config.action?.modalTitle || "",
|
||||||
|
editModalTitle: config.action?.editModalTitle || "",
|
||||||
|
editModalDescription: config.action?.editModalDescription || "",
|
||||||
|
targetUrl: config.action?.targetUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localSelects, setLocalSelects] = useState({
|
||||||
|
variant: config.variant || "default",
|
||||||
|
size: config.size || "md", // 🔧 기본값을 "md"로 변경
|
||||||
|
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
|
||||||
|
modalSize: config.action?.modalSize || "md",
|
||||||
|
editMode: config.action?.editMode || "modal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||||
|
const [screensLoading, setScreensLoading] = useState(false);
|
||||||
|
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
||||||
|
const [navScreenOpen, setNavScreenOpen] = useState(false);
|
||||||
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||||
|
const [navSearchTerm, setNavSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔄 ButtonConfigPanel useEffect 실행:", {
|
||||||
|
componentId: component.id,
|
||||||
|
"config.action?.type": config.action?.type,
|
||||||
|
"localSelects.actionType (before)": localSelects.actionType,
|
||||||
|
fullAction: config.action,
|
||||||
|
"component.componentConfig.action": component.componentConfig?.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalInputs({
|
||||||
|
text: config.text !== undefined ? config.text : "버튼", // 🔧 빈 문자열 허용
|
||||||
|
modalTitle: config.action?.modalTitle || "",
|
||||||
|
editModalTitle: config.action?.editModalTitle || "",
|
||||||
|
editModalDescription: config.action?.editModalDescription || "",
|
||||||
|
targetUrl: config.action?.targetUrl || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalSelects((prev) => {
|
||||||
|
const newSelects = {
|
||||||
|
variant: config.variant || "default",
|
||||||
|
size: config.size || "md", // 🔧 기본값을 "md"로 변경
|
||||||
|
actionType: config.action?.type, // 🔧 기본값 완전 제거 (undefined)
|
||||||
|
modalSize: config.action?.modalSize || "md",
|
||||||
|
editMode: config.action?.editMode || "modal",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📝 setLocalSelects 호출:", {
|
||||||
|
"prev.actionType": prev.actionType,
|
||||||
|
"new.actionType": newSelects.actionType,
|
||||||
|
"config.action?.type": config.action?.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSelects;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
component.id, // 🔧 컴포넌트 ID (다른 컴포넌트로 전환 시)
|
||||||
|
component.componentConfig?.action?.type, // 🔧 액션 타입 (액션 변경 시 즉시 반영)
|
||||||
|
component.componentConfig?.text, // 🔧 버튼 텍스트
|
||||||
|
component.componentConfig?.variant, // 🔧 버튼 스타일
|
||||||
|
component.componentConfig?.size, // 🔧 버튼 크기
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 화면 목록 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchScreens = async () => {
|
||||||
|
try {
|
||||||
|
setScreensLoading(true);
|
||||||
|
const response = await apiClient.get("/screen-management/screens");
|
||||||
|
|
||||||
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
|
const screenList = response.data.data.map((screen: any) => ({
|
||||||
|
id: screen.screenId,
|
||||||
|
name: screen.screenName,
|
||||||
|
description: screen.description,
|
||||||
|
}));
|
||||||
|
setScreens(screenList);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.error("❌ 화면 목록 로딩 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setScreensLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchScreens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 검색 필터링 함수
|
||||||
|
const filterScreens = (searchTerm: string) => {
|
||||||
|
if (!searchTerm.trim()) return screens;
|
||||||
|
return screens.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(screen.description && screen.description.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
||||||
|
component,
|
||||||
|
config,
|
||||||
|
action: config.action,
|
||||||
|
actionType: config.action?.type,
|
||||||
|
screensCount: screens.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
id="button-text"
|
||||||
|
value={localInputs.text}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, text: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.text", newValue);
|
||||||
|
}}
|
||||||
|
placeholder="버튼 텍스트를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-variant">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.variant}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, variant: value }));
|
||||||
|
onUpdateProperty("componentConfig.variant", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 스타일 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="primary">기본 (Primary)</SelectItem>
|
||||||
|
<SelectItem value="secondary">보조 (Secondary)</SelectItem>
|
||||||
|
<SelectItem value="danger">위험 (Danger)</SelectItem>
|
||||||
|
<SelectItem value="success">성공 (Success)</SelectItem>
|
||||||
|
<SelectItem value="outline">외곽선 (Outline)</SelectItem>
|
||||||
|
<SelectItem value="ghost">고스트 (Ghost)</SelectItem>
|
||||||
|
<SelectItem value="link">링크 (Link)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-size">버튼 글씨 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.size}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, size: value }));
|
||||||
|
onUpdateProperty("componentConfig.size", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 글씨 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">기본 (Default)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-action">버튼 액션</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.actionType || undefined}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
console.log("🔵 버튼 액션 변경 시작:", {
|
||||||
|
oldValue: localSelects.actionType,
|
||||||
|
newValue: value,
|
||||||
|
componentId: component.id,
|
||||||
|
"현재 component.componentConfig.action": component.componentConfig?.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setLocalSelects((prev) => {
|
||||||
|
console.log("📝 setLocalSelects (액션 변경):", {
|
||||||
|
"prev.actionType": prev.actionType,
|
||||||
|
"new.actionType": value,
|
||||||
|
});
|
||||||
|
return { ...prev, actionType: value };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 개별 속성만 업데이트
|
||||||
|
onUpdateProperty("componentConfig.action.type", value);
|
||||||
|
|
||||||
|
// 액션에 따른 라벨 색상 자동 설정 (별도 호출)
|
||||||
|
if (value === "delete") {
|
||||||
|
onUpdateProperty("style.labelColor", "#ef4444");
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("style.labelColor", "#212121");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ 버튼 액션 변경 완료");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="버튼 액션 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="save">저장</SelectItem>
|
||||||
|
<SelectItem value="cancel">취소</SelectItem>
|
||||||
|
<SelectItem value="delete">삭제</SelectItem>
|
||||||
|
<SelectItem value="edit">수정</SelectItem>
|
||||||
|
<SelectItem value="add">추가</SelectItem>
|
||||||
|
<SelectItem value="search">검색</SelectItem>
|
||||||
|
<SelectItem value="reset">초기화</SelectItem>
|
||||||
|
<SelectItem value="submit">제출</SelectItem>
|
||||||
|
<SelectItem value="close">닫기</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
|
<SelectItem value="control">제어 (조건 체크만)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 열기 액션 설정 */}
|
||||||
|
{localSelects.actionType === "modal" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">모달 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="modal-title">모달 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="modal-title"
|
||||||
|
placeholder="모달 제목을 입력하세요"
|
||||||
|
value={localInputs.modalTitle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="modal-size">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.modalSize}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="모달 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-screen-modal">대상 화면 선택</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"화면을 선택하세요..."
|
||||||
|
: "화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`modal-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수정 액션 설정 */}
|
||||||
|
{localSelects.actionType === "edit" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">수정 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-screen">수정 폼 화면 선택</Label>
|
||||||
|
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={modalScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"수정 폼 화면을 선택하세요..."
|
||||||
|
: "수정 폼 화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={modalSearchTerm}
|
||||||
|
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`edit-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setModalScreenOpen(false);
|
||||||
|
setModalSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
선택된 데이터가 이 폼 화면에 자동으로 로드되어 수정할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-mode">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.editMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, editMode: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.editMode", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="수정 모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="modal">모달로 열기</SelectItem>
|
||||||
|
<SelectItem value="navigate">새 페이지로 이동</SelectItem>
|
||||||
|
<SelectItem value="inline">현재 화면에서 수정</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localSelects.editMode === "modal" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-modal-title"
|
||||||
|
placeholder="모달 제목을 입력하세요 (예: 데이터 수정)"
|
||||||
|
value={localInputs.editModalTitle}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
|
||||||
|
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">비워두면 기본 제목이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-description">모달 설명</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-modal-description"
|
||||||
|
placeholder="모달 설명을 입력하세요 (예: 선택한 데이터를 수정합니다)"
|
||||||
|
value={localInputs.editModalDescription}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
|
||||||
|
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">비워두면 설명이 표시되지 않습니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
||||||
|
<Select
|
||||||
|
value={localSelects.modalSize}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
||||||
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="모달 크기 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
|
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||||
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
|
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||||
|
<SelectItem value="full">전체 화면 (Full)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지 이동 액션 설정 */}
|
||||||
|
{localSelects.actionType === "navigate" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">페이지 이동 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-screen-nav">이동할 화면 선택</Label>
|
||||||
|
<Popover open={navScreenOpen} onOpenChange={setNavScreenOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={navScreenOpen}
|
||||||
|
className="h-10 w-full justify-between"
|
||||||
|
disabled={screensLoading}
|
||||||
|
>
|
||||||
|
{config.action?.targetScreenId
|
||||||
|
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
|
||||||
|
"화면을 선택하세요..."
|
||||||
|
: "화면을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={navSearchTerm}
|
||||||
|
onChange={(e) => setNavSearchTerm(e.target.value)}
|
||||||
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-auto">
|
||||||
|
{(() => {
|
||||||
|
const filteredScreens = filterScreens(navSearchTerm);
|
||||||
|
if (screensLoading) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">화면 목록을 불러오는 중...</div>;
|
||||||
|
}
|
||||||
|
if (filteredScreens.length === 0) {
|
||||||
|
return <div className="p-3 text-sm text-gray-500">검색 결과가 없습니다.</div>;
|
||||||
|
}
|
||||||
|
return filteredScreens.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`navigate-screen-${screen.id}-${index}`}
|
||||||
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
|
setNavScreenOpen(false);
|
||||||
|
setNavSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.name}</span>
|
||||||
|
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
선택한 화면으로 /screens/{"{"}화면ID{"}"} 형태로 이동합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-url">또는 직접 URL 입력 (고급)</Label>
|
||||||
|
<Input
|
||||||
|
id="target-url"
|
||||||
|
placeholder="예: /admin/users 또는 https://example.com"
|
||||||
|
value={localInputs.targetUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
||||||
|
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🔥 NEW: 제어관리 기능 섹션 */}
|
||||||
|
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">🔧 고급 기능</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">버튼 액션과 함께 실행될 추가 기능을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -26,25 +26,24 @@ interface ScreenOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component, onUpdateProperty }) => {
|
||||||
|
console.log("🎨 ButtonConfigPanel 렌더링:", {
|
||||||
|
componentId: component.id,
|
||||||
|
"component.componentConfig?.action?.type": component.componentConfig?.action?.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||||
const config = component.componentConfig || {};
|
const config = component.componentConfig || {};
|
||||||
|
const currentAction = component.componentConfig?.action || {};
|
||||||
|
|
||||||
// 로컬 상태 관리 (실시간 입력 반영)
|
// 로컬 상태 관리 (실시간 입력 반영)
|
||||||
const [localInputs, setLocalInputs] = useState({
|
const [localInputs, setLocalInputs] = useState({
|
||||||
text: config.text || "버튼",
|
text: config.text !== undefined ? config.text : "버튼",
|
||||||
modalTitle: config.action?.modalTitle || "",
|
modalTitle: config.action?.modalTitle || "",
|
||||||
editModalTitle: config.action?.editModalTitle || "",
|
editModalTitle: config.action?.editModalTitle || "",
|
||||||
editModalDescription: config.action?.editModalDescription || "",
|
editModalDescription: config.action?.editModalDescription || "",
|
||||||
targetUrl: config.action?.targetUrl || "",
|
targetUrl: config.action?.targetUrl || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [localSelects, setLocalSelects] = useState({
|
|
||||||
variant: config.variant || "default",
|
|
||||||
size: config.size || "default",
|
|
||||||
actionType: config.action?.type || "save",
|
|
||||||
modalSize: config.action?.modalSize || "md",
|
|
||||||
editMode: config.action?.editMode || "modal",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||||
const [screensLoading, setScreensLoading] = useState(false);
|
const [screensLoading, setScreensLoading] = useState(false);
|
||||||
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
const [modalScreenOpen, setModalScreenOpen] = useState(false);
|
||||||
|
|
@ -52,44 +51,27 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||||
const [navSearchTerm, setNavSearchTerm] = useState("");
|
const [navSearchTerm, setNavSearchTerm] = useState("");
|
||||||
|
|
||||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalInputs({
|
const latestConfig = component.componentConfig || {};
|
||||||
text: config.text || "버튼",
|
const latestAction = latestConfig.action || {};
|
||||||
modalTitle: config.action?.modalTitle || "",
|
|
||||||
editModalTitle: config.action?.editModalTitle || "",
|
|
||||||
editModalDescription: config.action?.editModalDescription || "",
|
|
||||||
targetUrl: config.action?.targetUrl || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
setLocalSelects({
|
setLocalInputs({
|
||||||
variant: config.variant || "default",
|
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
|
||||||
size: config.size || "default",
|
modalTitle: latestAction.modalTitle || "",
|
||||||
actionType: config.action?.type || "save",
|
editModalTitle: latestAction.editModalTitle || "",
|
||||||
modalSize: config.action?.modalSize || "md",
|
editModalDescription: latestAction.editModalDescription || "",
|
||||||
editMode: config.action?.editMode || "modal",
|
targetUrl: latestAction.targetUrl || "",
|
||||||
});
|
});
|
||||||
}, [
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
config.text,
|
}, [component.id]);
|
||||||
config.variant,
|
|
||||||
config.size,
|
|
||||||
config.action?.type,
|
|
||||||
config.action?.modalTitle,
|
|
||||||
config.action?.modalSize,
|
|
||||||
config.action?.editMode,
|
|
||||||
config.action?.editModalTitle,
|
|
||||||
config.action?.editModalDescription,
|
|
||||||
config.action?.targetUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 화면 목록 가져오기
|
// 화면 목록 가져오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchScreens = async () => {
|
const fetchScreens = async () => {
|
||||||
try {
|
try {
|
||||||
setScreensLoading(true);
|
setScreensLoading(true);
|
||||||
// console.log("🔍 화면 목록 API 호출 시작");
|
|
||||||
const response = await apiClient.get("/screen-management/screens");
|
const response = await apiClient.get("/screen-management/screens");
|
||||||
// console.log("✅ 화면 목록 API 응답:", response.data);
|
|
||||||
|
|
||||||
if (response.data.success && Array.isArray(response.data.data)) {
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
const screenList = response.data.data.map((screen: any) => ({
|
const screenList = response.data.data.map((screen: any) => ({
|
||||||
|
|
@ -98,7 +80,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
description: screen.description,
|
description: screen.description,
|
||||||
}));
|
}));
|
||||||
setScreens(screenList);
|
setScreens(screenList);
|
||||||
// console.log("✅ 화면 목록 설정 완료:", screenList.length, "개");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("❌ 화면 목록 로딩 실패:", error);
|
// console.error("❌ 화면 목록 로딩 실패:", error);
|
||||||
|
|
@ -120,13 +101,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
// console.log("🔧 config-panels/ButtonConfigPanel 렌더링:", {
|
||||||
component,
|
// component,
|
||||||
config,
|
// config,
|
||||||
action: config.action,
|
// action: config.action,
|
||||||
actionType: config.action?.type,
|
// actionType: config.action?.type,
|
||||||
screensCount: screens.length,
|
// screensCount: screens.length,
|
||||||
});
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -147,9 +128,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="button-variant">버튼 스타일</Label>
|
<Label htmlFor="button-variant">버튼 스타일</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.variant}
|
value={component.componentConfig?.variant || "default"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setLocalSelects((prev) => ({ ...prev, variant: value }));
|
|
||||||
onUpdateProperty("componentConfig.variant", value);
|
onUpdateProperty("componentConfig.variant", value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -169,21 +149,20 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="button-size">버튼 크기</Label>
|
<Label htmlFor="button-size">버튼 글씨 크기</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.size}
|
value={component.componentConfig?.size || "md"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setLocalSelects((prev) => ({ ...prev, size: value }));
|
|
||||||
onUpdateProperty("componentConfig.size", value);
|
onUpdateProperty("componentConfig.size", value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="버튼 크기 선택" />
|
<SelectValue placeholder="버튼 글씨 크기 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="small">작음 (Small)</SelectItem>
|
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||||
<SelectItem value="default">기본 (Default)</SelectItem>
|
<SelectItem value="md">기본 (Default)</SelectItem>
|
||||||
<SelectItem value="large">큼 (Large)</SelectItem>
|
<SelectItem value="lg">큼 (Large)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -191,28 +170,23 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="button-action">버튼 액션</Label>
|
<Label htmlFor="button-action">버튼 액션</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.actionType}
|
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
|
||||||
|
value={component.componentConfig?.action?.type || "save"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
console.log("🔵 버튼 액션 변경:", {
|
console.log("🎯 버튼 액션 드롭다운 변경:", {
|
||||||
oldValue: localSelects.actionType,
|
oldValue: component.componentConfig?.action?.type,
|
||||||
newValue: value,
|
newValue: value,
|
||||||
componentId: component.id,
|
});
|
||||||
});
|
|
||||||
|
// 🔥 action.type 업데이트
|
||||||
// 로컬 상태 업데이트
|
onUpdateProperty("componentConfig.action.type", value);
|
||||||
setLocalSelects((prev) => ({ ...prev, actionType: value }));
|
|
||||||
|
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
|
||||||
// 액션 타입 업데이트
|
setTimeout(() => {
|
||||||
onUpdateProperty("componentConfig.action.type", value);
|
const newColor = value === "delete" ? "#ef4444" : "#212121";
|
||||||
|
console.log("🎨 라벨 색상 업데이트:", { value, newColor });
|
||||||
// 액션에 따른 라벨 색상 자동 설정 (별도 호출)
|
onUpdateProperty("style.labelColor", newColor);
|
||||||
if (value === "delete") {
|
}, 100); // 0 → 100ms로 증가
|
||||||
// 삭제 액션일 때 빨간색으로 설정
|
|
||||||
onUpdateProperty("style.labelColor", "#ef4444");
|
|
||||||
} else {
|
|
||||||
// 다른 액션일 때 기본색으로 리셋
|
|
||||||
onUpdateProperty("style.labelColor", "#212121");
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -236,7 +210,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 모달 열기 액션 설정 */}
|
{/* 모달 열기 액션 설정 */}
|
||||||
{localSelects.actionType === "modal" && (
|
{(component.componentConfig?.action?.type || "save") === "modal" && (
|
||||||
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700">모달 설정</h4>
|
<h4 className="text-sm font-medium text-gray-700">모달 설정</h4>
|
||||||
|
|
||||||
|
|
@ -249,10 +223,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.modalTitle", newValue);
|
||||||
...config.action,
|
|
||||||
modalTitle: newValue,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -260,13 +231,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="modal-size">모달 크기</Label>
|
<Label htmlFor="modal-size">모달 크기</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.modalSize}
|
value={component.componentConfig?.action?.modalSize || "md"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
onUpdateProperty("componentConfig.action", {
|
|
||||||
...config.action,
|
|
||||||
modalSize: value,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -301,7 +268,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* 검색 입력 */}
|
|
||||||
<div className="flex items-center border-b px-3 py-2">
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -311,7 +277,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
className="border-0 p-0 focus-visible:ring-0"
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 검색 결과 */}
|
|
||||||
<div className="max-h-[200px] overflow-auto">
|
<div className="max-h-[200px] overflow-auto">
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredScreens = filterScreens(modalSearchTerm);
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
|
@ -326,10 +291,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
key={`modal-screen-${screen.id}-${index}`}
|
key={`modal-screen-${screen.id}-${index}`}
|
||||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
...config.action,
|
|
||||||
targetScreenId: screen.id,
|
|
||||||
});
|
|
||||||
setModalScreenOpen(false);
|
setModalScreenOpen(false);
|
||||||
setModalSearchTerm("");
|
setModalSearchTerm("");
|
||||||
}}
|
}}
|
||||||
|
|
@ -356,7 +318,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 수정 액션 설정 */}
|
{/* 수정 액션 설정 */}
|
||||||
{localSelects.actionType === "edit" && (
|
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
||||||
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
|
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700">수정 설정</h4>
|
<h4 className="text-sm font-medium text-gray-700">수정 설정</h4>
|
||||||
|
|
||||||
|
|
@ -380,7 +342,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* 검색 입력 */}
|
|
||||||
<div className="flex items-center border-b px-3 py-2">
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -390,7 +351,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
className="border-0 p-0 focus-visible:ring-0"
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 검색 결과 */}
|
|
||||||
<div className="max-h-[200px] overflow-auto">
|
<div className="max-h-[200px] overflow-auto">
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredScreens = filterScreens(modalSearchTerm);
|
const filteredScreens = filterScreens(modalSearchTerm);
|
||||||
|
|
@ -405,10 +365,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
key={`edit-screen-${screen.id}-${index}`}
|
key={`edit-screen-${screen.id}-${index}`}
|
||||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
...config.action,
|
|
||||||
targetScreenId: screen.id,
|
|
||||||
});
|
|
||||||
setModalScreenOpen(false);
|
setModalScreenOpen(false);
|
||||||
setModalSearchTerm("");
|
setModalSearchTerm("");
|
||||||
}}
|
}}
|
||||||
|
|
@ -438,13 +395,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-mode">수정 모드</Label>
|
<Label htmlFor="edit-mode">수정 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.editMode}
|
value={component.componentConfig?.action?.editMode || "modal"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setLocalSelects((prev) => ({ ...prev, editMode: value }));
|
onUpdateProperty("componentConfig.action.editMode", value);
|
||||||
onUpdateProperty("componentConfig.action", {
|
|
||||||
...config.action,
|
|
||||||
editMode: value,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -458,7 +411,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{localSelects.editMode === "modal" && (
|
{(component.componentConfig?.action?.editMode || "modal") === "modal" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
<Label htmlFor="edit-modal-title">모달 제목</Label>
|
||||||
|
|
@ -469,11 +422,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
|
setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue }));
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.editModalTitle", newValue);
|
||||||
...config.action,
|
|
||||||
editModalTitle: newValue,
|
|
||||||
});
|
|
||||||
// webTypeConfig에도 저장
|
|
||||||
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
onUpdateProperty("webTypeConfig.editModalTitle", newValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -489,11 +438,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
|
setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue }));
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.editModalDescription", newValue);
|
||||||
...config.action,
|
|
||||||
editModalDescription: newValue,
|
|
||||||
});
|
|
||||||
// webTypeConfig에도 저장
|
|
||||||
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
onUpdateProperty("webTypeConfig.editModalDescription", newValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -503,13 +448,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
<Label htmlFor="edit-modal-size">모달 크기</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localSelects.modalSize}
|
value={component.componentConfig?.action?.modalSize || "md"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setLocalSelects((prev) => ({ ...prev, modalSize: value }));
|
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||||
onUpdateProperty("componentConfig.action", {
|
|
||||||
...config.action,
|
|
||||||
modalSize: value,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -530,7 +471,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지 이동 액션 설정 */}
|
{/* 페이지 이동 액션 설정 */}
|
||||||
{localSelects.actionType === "navigate" && (
|
{(component.componentConfig?.action?.type || "save") === "navigate" && (
|
||||||
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700">페이지 이동 설정</h4>
|
<h4 className="text-sm font-medium text-gray-700">페이지 이동 설정</h4>
|
||||||
|
|
||||||
|
|
@ -554,7 +495,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* 검색 입력 */}
|
|
||||||
<div className="flex items-center border-b px-3 py-2">
|
<div className="flex items-center border-b px-3 py-2">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -564,7 +504,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
className="border-0 p-0 focus-visible:ring-0"
|
className="border-0 p-0 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 검색 결과 */}
|
|
||||||
<div className="max-h-[200px] overflow-auto">
|
<div className="max-h-[200px] overflow-auto">
|
||||||
{(() => {
|
{(() => {
|
||||||
const filteredScreens = filterScreens(navSearchTerm);
|
const filteredScreens = filterScreens(navSearchTerm);
|
||||||
|
|
@ -579,10 +518,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
key={`navigate-screen-${screen.id}-${index}`}
|
key={`navigate-screen-${screen.id}-${index}`}
|
||||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||||
...config.action,
|
|
||||||
targetScreenId: screen.id,
|
|
||||||
});
|
|
||||||
setNavScreenOpen(false);
|
setNavScreenOpen(false);
|
||||||
setNavSearchTerm("");
|
setNavSearchTerm("");
|
||||||
}}
|
}}
|
||||||
|
|
@ -618,10 +554,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
||||||
onUpdateProperty("componentConfig.action", {
|
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
||||||
...config.action,
|
|
||||||
targetUrl: newValue,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
<p className="mt-1 text-xs text-gray-500">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||||
|
|
@ -641,3 +574,4 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -822,7 +822,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
case "button":
|
case "button":
|
||||||
case "button-primary":
|
case "button-primary":
|
||||||
case "button-secondary":
|
case "button-secondary":
|
||||||
return <NewButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||||||
|
return <NewButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
case "card":
|
case "card":
|
||||||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
case "button":
|
case "button":
|
||||||
case "button-primary":
|
case "button-primary":
|
||||||
case "button-secondary":
|
case "button-secondary":
|
||||||
return <ButtonConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||||||
|
return <ButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
||||||
case "card":
|
case "card":
|
||||||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,36 @@ export interface ExternalDbConnection {
|
||||||
updated_by?: string;
|
updated_by?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||||
|
|
||||||
|
export interface ExternalApiConnection {
|
||||||
|
id?: number;
|
||||||
|
connection_name: string;
|
||||||
|
description?: string;
|
||||||
|
base_url: string;
|
||||||
|
default_headers: Record<string, string>;
|
||||||
|
auth_type: AuthType;
|
||||||
|
auth_config?: {
|
||||||
|
keyLocation?: "header" | "query";
|
||||||
|
keyName?: string;
|
||||||
|
keyValue?: string;
|
||||||
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
timeout?: number;
|
||||||
|
company_code: string;
|
||||||
|
is_active: string;
|
||||||
|
created_date?: Date;
|
||||||
|
created_by?: string;
|
||||||
|
updated_date?: Date;
|
||||||
|
updated_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExternalDbConnectionFilter {
|
export interface ExternalDbConnectionFilter {
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
is_active?: string;
|
is_active?: string;
|
||||||
|
|
@ -209,7 +239,7 @@ export class ExternalDbConnectionAPI {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
|
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
|
||||||
`${this.BASE_PATH}/${connectionId}/test`,
|
`${this.BASE_PATH}/${connectionId}/test`,
|
||||||
password ? { password } : undefined
|
password ? { password } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
|
|
@ -220,10 +250,12 @@ export class ExternalDbConnectionAPI {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data.data || {
|
return (
|
||||||
success: true,
|
response.data.data || {
|
||||||
message: response.data.message || "연결 테스트가 완료되었습니다.",
|
success: true,
|
||||||
};
|
message: response.data.message || "연결 테스트가 완료되었습니다.",
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("연결 테스트 오류:", error);
|
console.error("연결 테스트 오류:", error);
|
||||||
|
|
||||||
|
|
@ -246,9 +278,7 @@ export class ExternalDbConnectionAPI {
|
||||||
*/
|
*/
|
||||||
static async getTables(connectionId: number): Promise<ApiResponse<string[]>> {
|
static async getTables(connectionId: number): Promise<ApiResponse<string[]>> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ApiResponse<string[]>>(
|
const response = await apiClient.get<ApiResponse<string[]>>(`${this.BASE_PATH}/${connectionId}/tables`);
|
||||||
`${this.BASE_PATH}/${connectionId}/tables`
|
|
||||||
);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 목록 조회 오류:", error);
|
console.error("테이블 목록 조회 오류:", error);
|
||||||
|
|
@ -260,7 +290,7 @@ export class ExternalDbConnectionAPI {
|
||||||
try {
|
try {
|
||||||
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
|
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
|
||||||
const response = await apiClient.get<ApiResponse<any[]>>(
|
const response = await apiClient.get<ApiResponse<any[]>>(
|
||||||
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`
|
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`,
|
||||||
);
|
);
|
||||||
console.log("컬럼 정보 API 응답:", response.data);
|
console.log("컬럼 정보 API 응답:", response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -273,10 +303,7 @@ export class ExternalDbConnectionAPI {
|
||||||
static async executeQuery(connectionId: number, query: string): Promise<ApiResponse<any[]>> {
|
static async executeQuery(connectionId: number, query: string): Promise<ApiResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query });
|
console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query });
|
||||||
const response = await apiClient.post<ApiResponse<any[]>>(
|
const response = await apiClient.post<ApiResponse<any[]>>(`${this.BASE_PATH}/${connectionId}/execute`, { query });
|
||||||
`${this.BASE_PATH}/${connectionId}/execute`,
|
|
||||||
{ query }
|
|
||||||
);
|
|
||||||
console.log("API 응답:", response.data);
|
console.log("API 응답:", response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -284,4 +311,45 @@ export class ExternalDbConnectionAPI {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 연결 목록 조회 (외부 커넥션에서)
|
||||||
|
*/
|
||||||
|
static async getApiConnections(filter: { is_active?: string } = {}): Promise<ExternalApiConnection[]> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||||
|
|
||||||
|
const response = await apiClient.get<ApiResponse<ExternalApiConnection[]>>(
|
||||||
|
`/external-rest-api-connections?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "API 연결 목록 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API 연결 목록 조회 오류:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 REST API 연결 조회
|
||||||
|
*/
|
||||||
|
static async getApiConnectionById(id: number): Promise<ExternalApiConnection | null> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<ExternalApiConnection>>(`/external-rest-api-connections/${id}`);
|
||||||
|
|
||||||
|
if (!response.data.success || !response.data.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("API 연결 조회 오류:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,114 @@ class TableManagementApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 테이블 로그 시스템 API
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
async createLogTable(
|
||||||
|
tableName: string,
|
||||||
|
pkColumn: { columnName: string; dataType: string },
|
||||||
|
): Promise<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log`, { pkColumn });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 로그 테이블 생성 실패: ${tableName}`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message || "로그 테이블을 생성할 수 없습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 설정 조회
|
||||||
|
*/
|
||||||
|
async getLogConfig(tableName: string): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
originalTableName: string;
|
||||||
|
logTableName: string;
|
||||||
|
triggerName: string;
|
||||||
|
triggerFunctionName: string;
|
||||||
|
isActive: string;
|
||||||
|
createdAt: Date;
|
||||||
|
createdBy: string;
|
||||||
|
} | null>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log/config`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 로그 설정 조회 실패: ${tableName}`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message || "로그 설정을 조회할 수 없습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 데이터 조회
|
||||||
|
*/
|
||||||
|
async getLogData(
|
||||||
|
tableName: string,
|
||||||
|
options: {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
operationType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
changedBy?: string;
|
||||||
|
originalId?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`${this.basePath}/tables/${tableName}/log`, {
|
||||||
|
params: options,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 로그 데이터 조회 실패: ${tableName}`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message || "로그 데이터를 조회할 수 없습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 활성화/비활성화
|
||||||
|
*/
|
||||||
|
async toggleLogTable(tableName: string, isActive: boolean): Promise<ApiResponse<void>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`${this.basePath}/tables/${tableName}/log/toggle`, {
|
||||||
|
isActive,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 로그 테이블 토글 실패: ${tableName}`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message || "로그 테이블 설정을 변경할 수 없습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 싱글톤 인스턴스 생성
|
// 싱글톤 인스턴스 생성
|
||||||
|
|
|
||||||
|
|
@ -491,7 +491,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
|
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
|
||||||
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
|
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
|
||||||
color: componentConfig.disabled ? "#9ca3af" : "white",
|
color: componentConfig.disabled ? "#9ca3af" : "white",
|
||||||
fontSize: "0.875rem",
|
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||||
|
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
|
@ -499,10 +500,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
padding: "0 1rem",
|
// 🔧 크기에 따른 패딩 조정
|
||||||
|
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
minHeight: "2.25rem",
|
|
||||||
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
|
|
@ -511,7 +512,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
{processedConfig.text || component.label || "버튼"}
|
{/* 🔧 빈 문자열도 허용 (undefined일 때만 기본값 적용) */}
|
||||||
|
{processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,773 @@
|
||||||
|
# 테이블 변경 이력 로그 시스템 구현 계획서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
테이블 생성 시 해당 테이블의 변경 이력을 자동으로 기록하는 로그 테이블 생성 기능을 추가합니다.
|
||||||
|
사용자가 테이블을 생성할 때 로그 테이블 생성 여부를 선택할 수 있으며, 선택 시 자동으로 로그 테이블과 트리거가 생성됩니다.
|
||||||
|
|
||||||
|
## 2. 핵심 기능
|
||||||
|
|
||||||
|
### 2.1 로그 테이블 생성 옵션
|
||||||
|
|
||||||
|
- 테이블 생성 폼에 "변경 이력 로그 테이블 생성" 체크박스 추가
|
||||||
|
- 체크 시 `{원본테이블명}_log` 형식의 로그 테이블 자동 생성
|
||||||
|
|
||||||
|
### 2.2 로그 테이블 스키마 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE {table_name}_log (
|
||||||
|
log_id SERIAL PRIMARY KEY, -- 로그 고유 ID
|
||||||
|
operation_type VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE
|
||||||
|
original_id {원본PK타입}, -- 원본 테이블의 PK 값
|
||||||
|
changed_column VARCHAR(100), -- 변경된 컬럼명 (UPDATE 시)
|
||||||
|
old_value TEXT, -- 변경 전 값
|
||||||
|
new_value TEXT, -- 변경 후 값
|
||||||
|
changed_by VARCHAR(50), -- 변경한 사용자 ID
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 변경 시각
|
||||||
|
ip_address VARCHAR(50), -- 변경 요청 IP
|
||||||
|
user_agent TEXT, -- 변경 요청 User-Agent
|
||||||
|
full_row_before JSONB, -- 변경 전 전체 행 (JSON)
|
||||||
|
full_row_after JSONB -- 변경 후 전체 행 (JSON)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_{table_name}_log_original_id ON {table_name}_log(original_id);
|
||||||
|
CREATE INDEX idx_{table_name}_log_changed_at ON {table_name}_log(changed_at);
|
||||||
|
CREATE INDEX idx_{table_name}_log_operation ON {table_name}_log(operation_type);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 트리거 함수 생성
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION {table_name}_log_trigger_func()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name TEXT;
|
||||||
|
v_old_value TEXT;
|
||||||
|
v_new_value TEXT;
|
||||||
|
v_user_id VARCHAR(50);
|
||||||
|
v_ip_address VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
-- 세션 변수에서 사용자 정보 가져오기
|
||||||
|
v_user_id := current_setting('app.user_id', TRUE);
|
||||||
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||||
|
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
INSERT INTO {table_name}_log (
|
||||||
|
operation_type, original_id, changed_by, ip_address,
|
||||||
|
full_row_after
|
||||||
|
) VALUES (
|
||||||
|
'INSERT', NEW.{pk_column}, v_user_id, v_ip_address,
|
||||||
|
row_to_json(NEW)::jsonb
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
-- 각 컬럼별로 변경사항 기록
|
||||||
|
FOR v_column_name IN
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = TG_TABLE_NAME
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT',
|
||||||
|
v_column_name, v_column_name)
|
||||||
|
INTO v_old_value, v_new_value
|
||||||
|
USING OLD, NEW;
|
||||||
|
|
||||||
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
INSERT INTO {table_name}_log (
|
||||||
|
operation_type, original_id, changed_column,
|
||||||
|
old_value, new_value, changed_by, ip_address,
|
||||||
|
full_row_before, full_row_after
|
||||||
|
) VALUES (
|
||||||
|
'UPDATE', NEW.{pk_column}, v_column_name,
|
||||||
|
v_old_value, v_new_value, v_user_id, v_ip_address,
|
||||||
|
row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
INSERT INTO {table_name}_log (
|
||||||
|
operation_type, original_id, changed_by, ip_address,
|
||||||
|
full_row_before
|
||||||
|
) VALUES (
|
||||||
|
'DELETE', OLD.{pk_column}, v_user_id, v_ip_address,
|
||||||
|
row_to_json(OLD)::jsonb
|
||||||
|
);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 트리거 생성
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TRIGGER {table_name}_audit_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON {table_name}
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION {table_name}_log_trigger_func();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 데이터베이스 스키마 변경
|
||||||
|
|
||||||
|
### 3.1 table_type_mng 테이블 수정
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE table_type_mng
|
||||||
|
ADD COLUMN use_log_table VARCHAR(1) DEFAULT 'N';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN table_type_mng.use_log_table IS '변경 이력 로그 테이블 사용 여부 (Y/N)';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 새로운 관리 테이블 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE table_log_config (
|
||||||
|
config_id SERIAL PRIMARY KEY,
|
||||||
|
original_table_name VARCHAR(100) NOT NULL,
|
||||||
|
log_table_name VARCHAR(100) NOT NULL,
|
||||||
|
trigger_name VARCHAR(100) NOT NULL,
|
||||||
|
trigger_function_name VARCHAR(100) NOT NULL,
|
||||||
|
is_active VARCHAR(1) DEFAULT 'Y',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
UNIQUE(original_table_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE table_log_config IS '테이블 로그 설정 관리';
|
||||||
|
COMMENT ON COLUMN table_log_config.original_table_name IS '원본 테이블명';
|
||||||
|
COMMENT ON COLUMN table_log_config.log_table_name IS '로그 테이블명';
|
||||||
|
COMMENT ON COLUMN table_log_config.trigger_name IS '트리거명';
|
||||||
|
COMMENT ON COLUMN table_log_config.trigger_function_name IS '트리거 함수명';
|
||||||
|
COMMENT ON COLUMN table_log_config.is_active IS '활성 상태 (Y/N)';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 백엔드 구현
|
||||||
|
|
||||||
|
### 4.1 Service Layer 수정
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/services/admin/table-type-mng.service.ts`
|
||||||
|
|
||||||
|
#### 4.1.1 로그 테이블 생성 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 로그 테이블 생성
|
||||||
|
*/
|
||||||
|
private async createLogTable(
|
||||||
|
tableName: string,
|
||||||
|
columns: any[],
|
||||||
|
connectionId?: number,
|
||||||
|
userId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
const triggerFuncName = `${tableName}_log_trigger_func`;
|
||||||
|
const triggerName = `${tableName}_audit_trigger`;
|
||||||
|
|
||||||
|
// PK 컬럼 찾기
|
||||||
|
const pkColumn = columns.find(col => col.isPrimaryKey);
|
||||||
|
if (!pkColumn) {
|
||||||
|
throw new Error('PK 컬럼이 없으면 로그 테이블을 생성할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그 테이블 DDL 생성
|
||||||
|
const logTableDDL = this.generateLogTableDDL(
|
||||||
|
logTableName,
|
||||||
|
pkColumn.COLUMN_NAME,
|
||||||
|
pkColumn.DATA_TYPE
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트리거 함수 DDL 생성
|
||||||
|
const triggerFuncDDL = this.generateTriggerFunctionDDL(
|
||||||
|
triggerFuncName,
|
||||||
|
logTableName,
|
||||||
|
tableName,
|
||||||
|
pkColumn.COLUMN_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
// 트리거 DDL 생성
|
||||||
|
const triggerDDL = this.generateTriggerDDL(
|
||||||
|
triggerName,
|
||||||
|
tableName,
|
||||||
|
triggerFuncName
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 로그 테이블 생성
|
||||||
|
await this.executeDDL(logTableDDL, connectionId);
|
||||||
|
|
||||||
|
// 2. 트리거 함수 생성
|
||||||
|
await this.executeDDL(triggerFuncDDL, connectionId);
|
||||||
|
|
||||||
|
// 3. 트리거 생성
|
||||||
|
await this.executeDDL(triggerDDL, connectionId);
|
||||||
|
|
||||||
|
// 4. 로그 설정 저장
|
||||||
|
await this.saveLogConfig({
|
||||||
|
originalTableName: tableName,
|
||||||
|
logTableName,
|
||||||
|
triggerName,
|
||||||
|
triggerFunctionName: triggerFuncName,
|
||||||
|
createdBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`로그 테이블 생성 완료: ${logTableName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('로그 테이블 생성 실패:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 테이블 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateLogTableDDL(
|
||||||
|
logTableName: string,
|
||||||
|
pkColumnName: string,
|
||||||
|
pkDataType: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE TABLE ${logTableName} (
|
||||||
|
log_id SERIAL PRIMARY KEY,
|
||||||
|
operation_type VARCHAR(10) NOT NULL,
|
||||||
|
original_id ${pkDataType},
|
||||||
|
changed_column VARCHAR(100),
|
||||||
|
old_value TEXT,
|
||||||
|
new_value TEXT,
|
||||||
|
changed_by VARCHAR(50),
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
full_row_before JSONB,
|
||||||
|
full_row_after JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_${logTableName}_original_id ON ${logTableName}(original_id);
|
||||||
|
CREATE INDEX idx_${logTableName}_changed_at ON ${logTableName}(changed_at);
|
||||||
|
CREATE INDEX idx_${logTableName}_operation ON ${logTableName}(operation_type);
|
||||||
|
|
||||||
|
COMMENT ON TABLE ${logTableName} IS '${logTableName.replace('_log', '')} 테이블 변경 이력';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.operation_type IS '작업 유형 (INSERT/UPDATE/DELETE)';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.original_id IS '원본 테이블 PK 값';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.changed_column IS '변경된 컬럼명';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.old_value IS '변경 전 값';
|
||||||
|
COMMENT ON COLUMN ${logTableName}.new_value IS '변경 후 값';
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리거 함수 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateTriggerFunctionDDL(
|
||||||
|
funcName: string,
|
||||||
|
logTableName: string,
|
||||||
|
originalTableName: string,
|
||||||
|
pkColumnName: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE OR REPLACE FUNCTION ${funcName}()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_column_name TEXT;
|
||||||
|
v_old_value TEXT;
|
||||||
|
v_new_value TEXT;
|
||||||
|
v_user_id VARCHAR(50);
|
||||||
|
v_ip_address VARCHAR(50);
|
||||||
|
BEGIN
|
||||||
|
v_user_id := current_setting('app.user_id', TRUE);
|
||||||
|
v_ip_address := current_setting('app.ip_address', TRUE);
|
||||||
|
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
INSERT INTO ${logTableName} (
|
||||||
|
operation_type, original_id, changed_by, ip_address, full_row_after
|
||||||
|
) VALUES (
|
||||||
|
'INSERT', NEW.${pkColumnName}, v_user_id, v_ip_address, row_to_json(NEW)::jsonb
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
FOR v_column_name IN
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = '${originalTableName}'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_column_name, v_column_name)
|
||||||
|
INTO v_old_value, v_new_value
|
||||||
|
USING OLD, NEW;
|
||||||
|
|
||||||
|
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
||||||
|
INSERT INTO ${logTableName} (
|
||||||
|
operation_type, original_id, changed_column, old_value, new_value,
|
||||||
|
changed_by, ip_address, full_row_before, full_row_after
|
||||||
|
) VALUES (
|
||||||
|
'UPDATE', NEW.${pkColumnName}, v_column_name, v_old_value, v_new_value,
|
||||||
|
v_user_id, v_ip_address, row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF (TG_OP = 'DELETE') THEN
|
||||||
|
INSERT INTO ${logTableName} (
|
||||||
|
operation_type, original_id, changed_by, ip_address, full_row_before
|
||||||
|
) VALUES (
|
||||||
|
'DELETE', OLD.${pkColumnName}, v_user_id, v_ip_address, row_to_json(OLD)::jsonb
|
||||||
|
);
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리거 DDL 생성
|
||||||
|
*/
|
||||||
|
private generateTriggerDDL(
|
||||||
|
triggerName: string,
|
||||||
|
tableName: string,
|
||||||
|
funcName: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
CREATE TRIGGER ${triggerName}
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON ${tableName}
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ${funcName}();
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그 설정 저장
|
||||||
|
*/
|
||||||
|
private async saveLogConfig(config: {
|
||||||
|
originalTableName: string;
|
||||||
|
logTableName: string;
|
||||||
|
triggerName: string;
|
||||||
|
triggerFunctionName: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO table_log_config (
|
||||||
|
original_table_name, log_table_name, trigger_name,
|
||||||
|
trigger_function_name, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.executeQuery(query, [
|
||||||
|
config.originalTableName,
|
||||||
|
config.logTableName,
|
||||||
|
config.triggerName,
|
||||||
|
config.triggerFunctionName,
|
||||||
|
config.createdBy
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.1.2 테이블 생성 메서드 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createTable(params: {
|
||||||
|
tableName: string;
|
||||||
|
columns: any[];
|
||||||
|
useLogTable?: boolean; // 추가
|
||||||
|
connectionId?: number;
|
||||||
|
userId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { tableName, columns, useLogTable, connectionId, userId } = params;
|
||||||
|
|
||||||
|
// 1. 원본 테이블 생성
|
||||||
|
const ddl = this.generateCreateTableDDL(tableName, columns);
|
||||||
|
await this.executeDDL(ddl, connectionId);
|
||||||
|
|
||||||
|
// 2. 로그 테이블 생성 (옵션)
|
||||||
|
if (useLogTable === true) {
|
||||||
|
await this.createLogTable(tableName, columns, connectionId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 메타데이터 저장
|
||||||
|
await this.saveTableMetadata({
|
||||||
|
tableName,
|
||||||
|
columns,
|
||||||
|
useLogTable: useLogTable ? 'Y' : 'N',
|
||||||
|
connectionId,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Controller Layer 수정
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/controllers/admin/table-type-mng.controller.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 테이블 생성
|
||||||
|
*/
|
||||||
|
async createTable(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, columns, useLogTable, connectionId } = req.body;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
await this.tableTypeMngService.createTable({
|
||||||
|
tableName,
|
||||||
|
columns,
|
||||||
|
useLogTable: useLogTable === 'Y' || useLogTable === true,
|
||||||
|
connectionId,
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: useLogTable
|
||||||
|
? '테이블 및 로그 테이블이 생성되었습니다.'
|
||||||
|
: '테이블이 생성되었습니다.'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('테이블 생성 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '테이블 생성 중 오류가 발생했습니다.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 세션 변수 설정 미들웨어
|
||||||
|
|
||||||
|
**파일**: `backend-node/src/middleware/db-session.middleware.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 세션 변수 설정 미들웨어
|
||||||
|
* 트리거에서 사용할 사용자 정보를 세션 변수에 설정
|
||||||
|
*/
|
||||||
|
export const setDBSessionVariables = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId || "system";
|
||||||
|
const ipAddress = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
|
|
||||||
|
// PostgreSQL 세션 변수 설정
|
||||||
|
const queries = [
|
||||||
|
`SET app.user_id = '${userId}'`,
|
||||||
|
`SET app.ip_address = '${ipAddress}'`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 각 DB 연결에 세션 변수 설정
|
||||||
|
// (실제 구현은 DB 연결 풀 관리 방식에 따라 다름)
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DB 세션 변수 설정 오류:", error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 프론트엔드 구현
|
||||||
|
|
||||||
|
### 5.1 테이블 생성 폼 수정
|
||||||
|
|
||||||
|
**파일**: `frontend/src/app/admin/tableMng/components/TableCreateForm.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const TableCreateForm = () => {
|
||||||
|
const [useLogTable, setUseLogTable] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-create-form">
|
||||||
|
{/* 기존 폼 필드들 */}
|
||||||
|
|
||||||
|
{/* 로그 테이블 옵션 추가 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useLogTable}
|
||||||
|
onChange={(e) => setUseLogTable(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span>변경 이력 로그 테이블 생성</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
체크 시 데이터 변경 이력을 기록하는 로그 테이블이 자동으로 생성됩니다.
|
||||||
|
(테이블명: {tableName}_log)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useLogTable && (
|
||||||
|
<div className="bg-blue-50 p-4 rounded border border-blue-200">
|
||||||
|
<h4 className="font-semibold mb-2">로그 테이블 정보</h4>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
<li>• INSERT/UPDATE/DELETE 작업이 자동으로 기록됩니다</li>
|
||||||
|
<li>• 변경 전후 값과 변경자 정보가 저장됩니다</li>
|
||||||
|
<li>
|
||||||
|
• 로그는 별도 테이블에 저장되어 원본 테이블 성능에 영향을
|
||||||
|
최소화합니다
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 로그 조회 화면 추가
|
||||||
|
|
||||||
|
**파일**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TableLogViewerProps {
|
||||||
|
tableName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableLogViewer: React.FC<TableLogViewerProps> = ({ tableName }) => {
|
||||||
|
const [logs, setLogs] = useState<any[]>([]);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
operationType: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
changedBy: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
// 로그 데이터 조회
|
||||||
|
const response = await fetch(`/api/admin/table-log/${tableName}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(filters),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
setLogs(data.logs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="table-log-viewer">
|
||||||
|
<h3>변경 이력 조회: {tableName}</h3>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="filters">
|
||||||
|
<select
|
||||||
|
value={filters.operationType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({ ...filters, operationType: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="INSERT">추가</option>
|
||||||
|
<option value="UPDATE">수정</option>
|
||||||
|
<option value="DELETE">삭제</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* 날짜 필터, 사용자 필터 등 */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 테이블 */}
|
||||||
|
<table className="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>작업유형</th>
|
||||||
|
<th>원본ID</th>
|
||||||
|
<th>변경컬럼</th>
|
||||||
|
<th>변경전</th>
|
||||||
|
<th>변경후</th>
|
||||||
|
<th>변경자</th>
|
||||||
|
<th>변경시각</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.log_id}>
|
||||||
|
<td>{log.operation_type}</td>
|
||||||
|
<td>{log.original_id}</td>
|
||||||
|
<td>{log.changed_column}</td>
|
||||||
|
<td>{log.old_value}</td>
|
||||||
|
<td>{log.new_value}</td>
|
||||||
|
<td>{log.changed_by}</td>
|
||||||
|
<td>{log.changed_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. API 엔드포인트
|
||||||
|
|
||||||
|
### 6.1 로그 조회 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/table-log/:tableName
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"operationType": "UPDATE", // 선택: INSERT, UPDATE, DELETE
|
||||||
|
"startDate": "2024-01-01", // 선택
|
||||||
|
"endDate": "2024-12-31", // 선택
|
||||||
|
"changedBy": "user123", // 선택
|
||||||
|
"originalId": 123 // 선택
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"log_id": 1,
|
||||||
|
"operation_type": "UPDATE",
|
||||||
|
"original_id": "123",
|
||||||
|
"changed_column": "user_name",
|
||||||
|
"old_value": "홍길동",
|
||||||
|
"new_value": "김철수",
|
||||||
|
"changed_by": "admin",
|
||||||
|
"changed_at": "2024-10-21T10:30:00Z",
|
||||||
|
"ip_address": "192.168.1.100"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 로그 테이블 활성화/비활성화 API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/admin/table-log/:tableName/toggle
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"isActive": "Y" // Y 또는 N
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "로그 기능이 활성화되었습니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 테스트 계획
|
||||||
|
|
||||||
|
### 7.1 단위 테스트
|
||||||
|
|
||||||
|
- [ ] 로그 테이블 DDL 생성 함수 테스트
|
||||||
|
- [ ] 트리거 함수 DDL 생성 함수 테스트
|
||||||
|
- [ ] 트리거 DDL 생성 함수 테스트
|
||||||
|
- [ ] 로그 설정 저장 함수 테스트
|
||||||
|
|
||||||
|
### 7.2 통합 테스트
|
||||||
|
|
||||||
|
- [ ] 테이블 생성 시 로그 테이블 자동 생성 테스트
|
||||||
|
- [ ] INSERT 작업 시 로그 기록 테스트
|
||||||
|
- [ ] UPDATE 작업 시 로그 기록 테스트
|
||||||
|
- [ ] DELETE 작업 시 로그 기록 테스트
|
||||||
|
- [ ] 여러 컬럼 동시 변경 시 로그 기록 테스트
|
||||||
|
|
||||||
|
### 7.3 성능 테스트
|
||||||
|
|
||||||
|
- [ ] 대량 데이터 INSERT 시 성능 영향 측정
|
||||||
|
- [ ] 대량 데이터 UPDATE 시 성능 영향 측정
|
||||||
|
- [ ] 로그 테이블 크기 증가에 따른 성능 영향 측정
|
||||||
|
|
||||||
|
## 8. 주의사항 및 제약사항
|
||||||
|
|
||||||
|
### 8.1 성능 고려사항
|
||||||
|
|
||||||
|
- 트리거는 모든 변경 작업에 대해 실행되므로 성능 영향이 있을 수 있음
|
||||||
|
- 대량 데이터 처리 시 로그 테이블 크기가 급격히 증가할 수 있음
|
||||||
|
- 로그 테이블에 적절한 인덱스 설정 필요
|
||||||
|
|
||||||
|
### 8.2 운영 고려사항
|
||||||
|
|
||||||
|
- 로그 데이터의 보관 주기 정책 수립 필요
|
||||||
|
- 오래된 로그 데이터 아카이빙 전략 필요
|
||||||
|
- 로그 테이블의 정기적인 파티셔닝 고려
|
||||||
|
|
||||||
|
### 8.3 보안 고려사항
|
||||||
|
|
||||||
|
- 로그 데이터에는 민감한 정보가 포함될 수 있으므로 접근 권한 관리 필요
|
||||||
|
- 로그 데이터 자체의 무결성 보장 필요
|
||||||
|
- 로그 데이터의 암호화 저장 고려
|
||||||
|
|
||||||
|
## 9. 향후 확장 계획
|
||||||
|
|
||||||
|
### 9.1 로그 분석 기능
|
||||||
|
|
||||||
|
- 변경 패턴 분석
|
||||||
|
- 사용자별 변경 통계
|
||||||
|
- 시간대별 변경 추이
|
||||||
|
|
||||||
|
### 9.2 로그 알림 기능
|
||||||
|
|
||||||
|
- 특정 테이블/컬럼 변경 시 알림
|
||||||
|
- 비정상적인 대량 변경 감지
|
||||||
|
- 특정 사용자의 변경 작업 모니터링
|
||||||
|
|
||||||
|
### 9.3 로그 복원 기능
|
||||||
|
|
||||||
|
- 특정 시점으로 데이터 롤백
|
||||||
|
- 변경 이력 기반 데이터 복구
|
||||||
|
- 변경 이력 시각화
|
||||||
|
|
||||||
|
## 10. 마이그레이션 가이드
|
||||||
|
|
||||||
|
### 10.1 기존 테이블에 로그 기능 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 테이블에 로그 테이블 추가하는 API
|
||||||
|
POST /api/admin/table-log/:tableName/enable
|
||||||
|
|
||||||
|
// 실행 순서:
|
||||||
|
// 1. 로그 테이블 생성
|
||||||
|
// 2. 트리거 함수 생성
|
||||||
|
// 3. 트리거 생성
|
||||||
|
// 4. 로그 설정 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 로그 기능 제거
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 로그 기능 제거 API
|
||||||
|
POST /api/admin/table-log/:tableName/disable
|
||||||
|
|
||||||
|
// 실행 순서:
|
||||||
|
// 1. 트리거 삭제
|
||||||
|
// 2. 트리거 함수 삭제
|
||||||
|
// 3. 로그 테이블 삭제 (선택)
|
||||||
|
// 4. 로그 설정 비활성화
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 개발 우선순위
|
||||||
|
|
||||||
|
### Phase 1: 기본 기능 (필수)
|
||||||
|
|
||||||
|
1. DB 스키마 변경 (table_type_mng, table_log_config)
|
||||||
|
2. 로그 테이블 DDL 생성 로직
|
||||||
|
3. 트리거 함수/트리거 DDL 생성 로직
|
||||||
|
4. 테이블 생성 시 로그 테이블 자동 생성
|
||||||
|
|
||||||
|
### Phase 2: UI 개발
|
||||||
|
|
||||||
|
1. 테이블 생성 폼에 로그 옵션 추가
|
||||||
|
2. 로그 조회 화면 개발
|
||||||
|
3. 로그 필터링 기능
|
||||||
|
|
||||||
|
### Phase 3: 고급 기능
|
||||||
|
|
||||||
|
1. 로그 활성화/비활성화 기능
|
||||||
|
2. 기존 테이블에 로그 추가 기능
|
||||||
|
3. 로그 데이터 아카이빙 기능
|
||||||
|
|
||||||
|
### Phase 4: 분석 및 최적화
|
||||||
|
|
||||||
|
1. 로그 분석 대시보드
|
||||||
|
2. 성능 최적화
|
||||||
|
3. 로그 데이터 파티셔닝
|
||||||
Loading…
Reference in New Issue