From 687dccb52246231c80b49eaa236dd6b6ee6d4525 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 21 Oct 2025 12:51:57 +0900 Subject: [PATCH 01/21] =?UTF-8?q?=EC=95=8C=EB=A6=BC=20api=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/riskAlertCacheService.ts | 25 ++++++++-- .../dashboard/widgets/RiskAlertWidget.tsx | 48 +++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/backend-node/src/services/riskAlertCacheService.ts b/backend-node/src/services/riskAlertCacheService.ts index cc4de181..ce8b6089 100644 --- a/backend-node/src/services/riskAlertCacheService.ts +++ b/backend-node/src/services/riskAlertCacheService.ts @@ -34,16 +34,35 @@ export class RiskAlertCacheService { */ public startAutoRefresh(): void { console.log('๐Ÿ”„ ๋ฆฌ์Šคํฌ/์•Œ๋ฆผ ์ž๋™ ๊ฐฑ์‹  ์‹œ์ž‘ (10๋ถ„ ๊ฐ„๊ฒฉ)'); + console.log(' - ๊ธฐ์ƒํŠน๋ณด: ์ฆ‰์‹œ ํ˜ธ์ถœ'); + console.log(' - ๊ตํ†ต์‚ฌ๊ณ /๋„๋กœ๊ณต์‚ฌ: 10๋ถ„ ํ›„ ์ฒซ ํ˜ธ์ถœ'); - // ์ฆ‰์‹œ ์ฒซ ๊ฐฑ์‹  - this.refreshCache(); + // ๊ธฐ์ƒํŠน๋ณด๋งŒ ์ฆ‰์‹œ ํ˜ธ์ถœ (ITS API๋Š” 10๋ถ„ ํ›„๋ถ€ํ„ฐ) + this.refreshWeatherOnly(); - // 10๋ถ„๋งˆ๋‹ค ๊ฐฑ์‹  (600,000ms) + // 10๋ถ„๋งˆ๋‹ค ์ „์ฒด ๊ฐฑ์‹  (600,000ms) this.updateInterval = setInterval(() => { this.refreshCache(); }, 10 * 60 * 1000); } + /** + * ๊ธฐ์ƒํŠน๋ณด๋งŒ ๊ฐฑ์‹  (์žฌ์‹œ์ž‘ ์‹œ ์‚ฌ์šฉ) + */ + private async refreshWeatherOnly(): Promise { + 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); + } + } + /** * ์ž๋™ ๊ฐฑ์‹  ์ค‘์ง€ */ diff --git a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx index 98278fdd..de6b2af8 100644 --- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx @@ -33,11 +33,11 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { const [lastUpdated, setLastUpdated] = useState(null); const [newAlertIds, setNewAlertIds] = useState>(new Set()); - // ๋ฐ์ดํ„ฐ ๋กœ๋“œ (๋ฐฑ์—”๋“œ ํ†ตํ•ฉ ํ˜ธ์ถœ) + // ๋ฐ์ดํ„ฐ ๋กœ๋“œ (๋ฐฑ์—”๋“œ ์บ์‹œ ์กฐํšŒ) const loadData = async () => { setIsRefreshing(true); try { - // ๋ฐฑ์—”๋“œ API ํ˜ธ์ถœ (๊ตํ†ต์‚ฌ๊ณ , ๊ธฐ์ƒํŠน๋ณด, ๋„๋กœ๊ณต์‚ฌ ํ†ตํ•ฉ) + // ๋ฐฑ์—”๋“œ API ํ˜ธ์ถœ (์บ์‹œ๋œ ๋ฐ์ดํ„ฐ) const response = await apiClient.get<{ success: boolean; 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(); + 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(() => { loadData(); // 1๋ถ„๋งˆ๋‹ค ์ž๋™ ์ƒˆ๋กœ๊ณ ์นจ (60000ms) @@ -156,7 +198,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { {lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })} )} - From 74d287daa9744547a4080fd9a0cb7aae8835b9ac Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 15:08:41 +0900 Subject: [PATCH 02/21] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EB=A1=9C=EA=B7=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 265 ++++++ .../src/routes/tableManagementRoutes.ts | 32 + .../src/services/tableManagementService.ts | 406 +++++++++ frontend/app/(main)/admin/tableMng/page.tsx | 31 +- .../components/admin/CreateTableModal.tsx | 48 +- frontend/components/admin/TableLogViewer.tsx | 261 ++++++ frontend/lib/api/tableManagement.ts | 108 +++ ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md | 773 ++++++++++++++++++ 8 files changed, 1918 insertions(+), 6 deletions(-) create mode 100644 frontend/components/admin/TableLogViewer.tsx create mode 100644 ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index aac86625..d7b2bd74 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1048,3 +1048,268 @@ export async function updateColumnWebType( res.status(500).json(response); } } + +// ======================================== +// ๐ŸŽฏ ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ API +// ======================================== + +/** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ +export async function createLogTable( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { pkColumn } = req.body; + const userId = req.user?.userId; + + logger.info(`=== ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์‹œ์ž‘: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) { + const response: ApiResponse = { + 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 = { + success: true, + message: "๋กœ๊ทธ ํ…Œ์ด๋ธ”์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + + logger.info(`=== ๋กœ๊ทธ ์„ค์ • ์กฐํšŒ: ${tableName} ===`); + + if (!tableName) { + const response: ApiResponse = { + 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 = { + success: true, + message: "๋กœ๊ทธ ์„ค์ •์„ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค.", + data: logConfig, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ์„ค์ • ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + 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 { + 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 = { + 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 = { + success: true, + message: "๋กœ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค.", + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + const { isActive } = req.body; + + logger.info(`=== ๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€: ${tableName}, isActive: ${isActive} ===`); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "ํ…Œ์ด๋ธ”๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + error: { + code: "MISSING_TABLE_NAME", + details: "ํ…Œ์ด๋ธ”๋ช… ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }, + }; + res.status(400).json(response); + return; + } + + if (isActive === undefined || isActive === null) { + const response: ApiResponse = { + 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 = { + success: true, + message: `๋กœ๊ทธ ๊ธฐ๋Šฅ์ด ${isActive ? "ํ™œ์„ฑํ™”" : "๋น„ํ™œ์„ฑํ™”"}๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ:", error); + + const response: ApiResponse = { + success: false, + message: "๋กœ๊ทธ ํ…Œ์ด๋ธ” ํ† ๊ธ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + error: { + code: "LOG_TOGGLE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index c0b35b94..5e5ddf38 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -18,6 +18,10 @@ import { checkTableExists, getColumnWebTypes, checkDatabaseConnection, + createLogTable, + getLogConfig, + getLogData, + toggleLogTable, } from "../controllers/tableManagementController"; const router = express.Router(); @@ -148,4 +152,32 @@ router.put("/tables/:tableName/edit", editTableData); */ 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; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 83f3a696..10de1e73 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3118,4 +3118,410 @@ export class TableManagementService { // ๊ธฐ๋ณธ๊ฐ’ return "text"; } + + // ======================================== + // ๐ŸŽฏ ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ + // ======================================== + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + userId?: string + ): Promise { + 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(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(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 { + 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; + } + } } diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index e415fec8..74fd30af 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -20,6 +20,7 @@ import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; +import { TableLogViewer } from "@/components/admin/TableLogViewer"; // ๊ฐ€์ƒํ™” ์Šคํฌ๋กค๋ง์„ ์œ„ํ•œ ๊ฐ„๋‹จํ•œ ๊ตฌํ˜„ interface TableInfo { @@ -76,6 +77,10 @@ export default function TableManagementPage() { const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); + // ๋กœ๊ทธ ๋ทฐ์–ด ์ƒํƒœ + const [logViewerOpen, setLogViewerOpen] = useState(false); + const [logViewerTableName, setLogViewerTableName] = useState(""); + // ์ตœ๊ณ  ๊ด€๋ฆฌ์ž ์—ฌ๋ถ€ ํ™•์ธ (ํšŒ์‚ฌ์ฝ”๋“œ๊ฐ€ "*"์ธ ๊ฒฝ์šฐ) const isSuperAdmin = user?.companyCode === "*"; @@ -645,15 +650,30 @@ export default function TableManagementPage() { onClick={() => handleTableSelect(table.tableName)} >
-
+

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "์„ค๋ช… ์—†์Œ")}

- - {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "์ปฌ๋Ÿผ")} - +
+ + {table.columnCount} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_COLUMN_COUNT, "์ปฌ๋Ÿผ")} + + +
)) @@ -972,6 +992,9 @@ export default function TableManagementPage() { /> setDdlLogViewerOpen(false)} /> + + {/* ํ…Œ์ด๋ธ” ๋กœ๊ทธ ๋ทฐ์–ด */} + )} diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index 7e075ad1..c31482dc 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -19,10 +19,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; 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 { ColumnDefinitionTable } from "./ColumnDefinitionTable"; import { ddlApi } from "../../lib/api/ddl"; +import { tableManagementApi } from "../../lib/api/tableManagement"; import { CreateTableModalProps, CreateColumnDefinition, @@ -47,6 +49,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa const [validating, setValidating] = useState(false); const [tableNameError, setTableNameError] = useState(""); const [validationResult, setValidationResult] = useState(null); + const [useLogTable, setUseLogTable] = useState(false); /** * ๋ชจ๋‹ฌ ๋ฆฌ์…‹ @@ -65,6 +68,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa ]); setTableNameError(""); setValidationResult(null); + setUseLogTable(false); }; /** @@ -204,6 +208,23 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa if (result.success) { 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); onClose(); } else { @@ -248,7 +269,7 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa placeholder="์˜ˆ: customer_info" className={tableNameError ? "border-red-300" : ""} /> - {tableNameError &&

{tableNameError}

} + {tableNameError &&

{tableNameError}

}

์˜๋ฌธ์ž๋กœ ์‹œ์ž‘, ์˜๋ฌธ์ž/์ˆซ์ž/์–ธ๋”์Šค์ฝ”์–ด๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

@@ -278,6 +299,29 @@ export function CreateTableModal({ isOpen, onClose, onSuccess }: CreateTableModa + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ ์˜ต์…˜ */} +
+ setUseLogTable(checked as boolean)} + disabled={loading} + /> +
+ +

+ ์„ ํƒ ์‹œ {tableName || "table"}_log ํ…Œ์ด๋ธ”์ด + ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋˜์–ด INSERT/UPDATE/DELETE ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. +

+
+
+ {/* ์ž๋™ ์ถ”๊ฐ€ ์ปฌ๋Ÿผ ์•ˆ๋‚ด */} diff --git a/frontend/components/admin/TableLogViewer.tsx b/frontend/components/admin/TableLogViewer.tsx new file mode 100644 index 00000000..6b899bf6 --- /dev/null +++ b/frontend/components/admin/TableLogViewer.tsx @@ -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([]); + 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(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [changedBy, setChangedBy] = useState(""); + const [originalId, setOriginalId] = useState(""); + + // ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + 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 ์ถ”๊ฐ€; + case "UPDATE": + return ์ˆ˜์ •; + case "DELETE": + return ์‚ญ์ œ; + default: + return {type}; + } + }; + + // ๋‚ ์งœ ํฌ๋งทํŒ… + 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 ( + + + + + + {tableName} - ๋ณ€๊ฒฝ ์ด๋ ฅ + + + + {/* ํ•„ํ„ฐ ์˜์—ญ */} +
+
+

+ + ํ•„ํ„ฐ +

+ +
+ +
+
+ + +
+ +
+ + setStartDate(e.target.value)} /> +
+ +
+ + setEndDate(e.target.value)} /> +
+ +
+ + setChangedBy(e.target.value)} /> +
+ +
+ + setOriginalId(e.target.value)} /> +
+ +
+ +
+
+
+ + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” */} +
+ {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
๋ณ€๊ฒฝ ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.
+ ) : ( + + + + ์ž‘์—… + ์›๋ณธ ID + ๋ณ€๊ฒฝ ์ปฌ๋Ÿผ + ๋ณ€๊ฒฝ ์ „ + ๋ณ€๊ฒฝ ํ›„ + ๋ณ€๊ฒฝ์ž + ๋ณ€๊ฒฝ ์‹œ๊ฐ + IP + + + + {logs.map((log) => ( + + {getOperationBadge(log.operation_type)} + {log.original_id} + {log.changed_column || "-"} + + {log.old_value || "-"} + + + {log.new_value || "-"} + + {log.changed_by || "system"} + {formatDate(log.changed_at)} + {log.ip_address || "-"} + + ))} + +
+ )} +
+ + {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */} +
+
+ ์ „์ฒด {total}๊ฑด (ํŽ˜์ด์ง€ {page} / {totalPages}) +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index 5dc1cc0a..6a8363ba 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -211,6 +211,114 @@ class TableManagementApi { }; } } + + // ======================================== + // ํ…Œ์ด๋ธ” ๋กœ๊ทธ ์‹œ์Šคํ…œ API + // ======================================== + + /** + * ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ƒ์„ฑ + */ + async createLogTable( + tableName: string, + pkColumn: { columnName: string; dataType: string }, + ): Promise> { + 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> { + 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, + }; + } + } } // ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ diff --git a/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md b/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md new file mode 100644 index 00000000..e7f43773 --- /dev/null +++ b/ํ…Œ์ด๋ธ”_๋ณ€๊ฒฝ_์ด๋ ฅ_๋กœ๊ทธ_์‹œ์Šคํ…œ_๊ตฌํ˜„_๊ณ„ํš์„œ.md @@ -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 { + 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 { + 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 { + 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 { + 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 => { + 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(false); + + return ( +
+ {/* ๊ธฐ์กด ํผ ํ•„๋“œ๋“ค */} + + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” ์˜ต์…˜ ์ถ”๊ฐ€ */} +
+ +

+ ์ฒดํฌ ์‹œ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๊ธฐ๋กํ•˜๋Š” ๋กœ๊ทธ ํ…Œ์ด๋ธ”์ด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. + (ํ…Œ์ด๋ธ”๋ช…: {tableName}_log) +

+
+ + {useLogTable && ( +
+

๋กœ๊ทธ ํ…Œ์ด๋ธ” ์ •๋ณด

+
    +
  • โ€ข INSERT/UPDATE/DELETE ์ž‘์—…์ด ์ž๋™์œผ๋กœ ๊ธฐ๋ก๋ฉ๋‹ˆ๋‹ค
  • +
  • โ€ข ๋ณ€๊ฒฝ ์ „ํ›„ ๊ฐ’๊ณผ ๋ณ€๊ฒฝ์ž ์ •๋ณด๊ฐ€ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค
  • +
  • + โ€ข ๋กœ๊ทธ๋Š” ๋ณ„๋„ ํ…Œ์ด๋ธ”์— ์ €์žฅ๋˜์–ด ์›๋ณธ ํ…Œ์ด๋ธ” ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ + ์ตœ์†Œํ™”ํ•ฉ๋‹ˆ๋‹ค +
  • +
+
+ )} +
+ ); +}; +``` + +### 5.2 ๋กœ๊ทธ ์กฐํšŒ ํ™”๋ฉด ์ถ”๊ฐ€ + +**ํŒŒ์ผ**: `frontend/src/app/admin/tableMng/components/TableLogViewer.tsx` + +```typescript +interface TableLogViewerProps { + tableName: string; +} + +const TableLogViewer: React.FC = ({ tableName }) => { + const [logs, setLogs] = useState([]); + 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 ( +
+

๋ณ€๊ฒฝ ์ด๋ ฅ ์กฐํšŒ: {tableName}

+ + {/* ํ•„ํ„ฐ */} +
+ + + {/* ๋‚ ์งœ ํ•„ํ„ฐ, ์‚ฌ์šฉ์ž ํ•„ํ„ฐ ๋“ฑ */} +
+ + {/* ๋กœ๊ทธ ํ…Œ์ด๋ธ” */} + + + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + + ))} + +
์ž‘์—…์œ ํ˜•์›๋ณธID๋ณ€๊ฒฝ์ปฌ๋Ÿผ๋ณ€๊ฒฝ์ „๋ณ€๊ฒฝํ›„๋ณ€๊ฒฝ์ž๋ณ€๊ฒฝ์‹œ๊ฐ
{log.operation_type}{log.original_id}{log.changed_column}{log.old_value}{log.new_value}{log.changed_by}{log.changed_at}
+
+ ); +}; +``` + +## 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. ๋กœ๊ทธ ๋ฐ์ดํ„ฐ ํŒŒํ‹ฐ์…”๋‹ From 656f1c2ebd6508449f5a28fdfc996d6077758e89 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 15:25:05 +0900 Subject: [PATCH 03/21] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EB=A1=9C=EA=B7=B8=EC=97=90=20?= =?UTF-8?q?ip=5Faddress=EC=99=80=20=EB=B3=80=EA=B2=BD=EC=9E=90=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EA=B8=B0=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/dynamicFormController.ts | 10 +++++++++- .../src/services/dynamicFormService.ts | 19 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index bc3e6f52..a5b2f225 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -36,10 +36,18 @@ export const saveFormData = async ( 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( screenId, tableName, - formDataWithMeta + formDataWithMeta, + ipAddress ); res.json({ diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 331f980e..999ea6d2 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,4 +1,4 @@ -import { query, queryOne } from "../database/db"; +import { query, queryOne, transaction } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; @@ -203,7 +203,8 @@ export class DynamicFormService { async saveFormData( screenId: number, tableName: string, - data: Record + data: Record, + ipAddress?: string ): Promise { try { console.log("๐Ÿ’พ ์„œ๋น„์Šค: ์‹ค์ œ ํ…Œ์ด๋ธ”์— ํผ ๋ฐ์ดํ„ฐ ์ €์žฅ ์‹œ์ž‘:", { @@ -432,7 +433,19 @@ export class DynamicFormService { console.log("๐Ÿ“ ์‹คํ–‰ํ•  UPSERT SQL:", upsertQuery); console.log("๐Ÿ“Š SQL ํŒŒ๋ผ๋ฏธํ„ฐ:", values); - const result = await query(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); From d57756189f6ee7952e6c294628c1edc68b94ee32 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 15:53:17 +0900 Subject: [PATCH 04/21] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/DashboardController.ts | 27 ++++++- backend-node/src/services/DashboardService.ts | 74 ++++++++++++++----- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index 7d710110..601e035c 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -24,6 +24,8 @@ export class DashboardController { ): Promise { try { const userId = req.user?.userId; + const companyCode = req.user?.companyCode; + if (!userId) { res.status(401).json({ success: false, @@ -89,7 +91,8 @@ export class DashboardController { const savedDashboard = await DashboardService.createDashboard( dashboardData, - userId + userId, + companyCode ); // console.log('๋Œ€์‹œ๋ณด๋“œ ์ƒ์„ฑ ์„ฑ๊ณต:', { id: savedDashboard.id, title: savedDashboard.title }); @@ -121,6 +124,7 @@ export class DashboardController { async getDashboards(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; + const companyCode = req.user?.companyCode; const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, @@ -145,7 +149,11 @@ export class DashboardController { return; } - const result = await DashboardService.getDashboards(query, userId); + const result = await DashboardService.getDashboards( + query, + userId, + companyCode + ); res.json({ success: true, @@ -173,6 +181,7 @@ export class DashboardController { try { const { id } = req.params; const userId = req.user?.userId; + const companyCode = req.user?.companyCode; if (!id) { res.status(400).json({ @@ -182,7 +191,11 @@ export class DashboardController { return; } - const dashboard = await DashboardService.getDashboardById(id, userId); + const dashboard = await DashboardService.getDashboardById( + id, + userId, + companyCode + ); if (!dashboard) { res.status(404).json({ @@ -393,6 +406,8 @@ export class DashboardController { return; } + const companyCode = req.user?.companyCode; + const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), @@ -401,7 +416,11 @@ export class DashboardController { createdBy: userId, // ๋ณธ์ธ์ด ๋งŒ๋“  ๋Œ€์‹œ๋ณด๋“œ๋งŒ }; - const result = await DashboardService.getDashboards(query, userId); + const result = await DashboardService.getDashboards( + query, + userId, + companyCode + ); res.json({ success: true, diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index c7650df2..68cc582f 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -18,7 +18,8 @@ export class DashboardService { */ static async createDashboard( data: CreateDashboardRequest, - userId: string + userId: string, + companyCode?: string ): Promise { const dashboardId = uuidv4(); const now = new Date(); @@ -31,8 +32,8 @@ export class DashboardService { ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count, settings - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + created_at, updated_at, tags, category, view_count, settings, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) `, [ dashboardId, @@ -46,6 +47,7 @@ export class DashboardService { data.category || null, 0, 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 { page = 1, limit = 20, @@ -161,6 +167,13 @@ export class DashboardService { let params: any[] = []; let paramIndex = 1; + // ํšŒ์‚ฌ ์ฝ”๋“œ ํ•„ํ„ฐ๋ง (์ตœ์šฐ์„ ) + if (companyCode) { + whereConditions.push(`d.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + // ๊ถŒํ•œ ํ•„ํ„ฐ๋ง if (userId) { whereConditions.push( @@ -278,7 +291,8 @@ export class DashboardService { */ static async getDashboardById( dashboardId: string, - userId?: string + userId?: string, + companyCode?: string ): Promise { try { // 1. ๋Œ€์‹œ๋ณด๋“œ ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ (๊ถŒํ•œ ์ฒดํฌ ํฌํ•จ) @@ -286,21 +300,43 @@ export class DashboardService { let dashboardParams: any[]; if (userId) { - 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]; + if (companyCode) { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + 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 { - dashboardQuery = ` - SELECT d.* - FROM dashboards d - WHERE d.id = $1 AND d.deleted_at IS NULL - AND d.is_public = true - `; - dashboardParams = [dashboardId]; + if (companyCode) { + dashboardQuery = ` + SELECT d.* + FROM dashboards d + WHERE d.id = $1 AND d.deleted_at IS NULL + AND d.company_code = $2 + 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( From 5ca0a6b6dc6877fc22007344b8aa31f63a7ff5b2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:23:34 +0900 Subject: [PATCH 05/21] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=95=84=EC=9B=83=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 292 +++++++++---------- 1 file changed, 146 insertions(+), 146 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index d1ca6125..97c1036c 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,20 +1,14 @@ "use client"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { dashboardApi } from "@/lib/api/dashboard"; import { Dashboard } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { AlertDialog, AlertDialogAction, @@ -25,8 +19,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreVertical } from "lucide-react"; /** * ๋Œ€์‹œ๋ณด๋“œ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ @@ -35,27 +29,28 @@ import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lu */ export default function DashboardListPage() { const router = useRouter(); + const { toast } = useToast(); const [dashboards, setDashboards] = useState([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); - const [error, setError] = useState(null); // ๋ชจ๋‹ฌ ์ƒํƒœ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); - const [successDialogOpen, setSuccessDialogOpen] = useState(false); - const [successMessage, setSuccessMessage] = useState(""); // ๋Œ€์‹œ๋ณด๋“œ ๋ชฉ๋ก ๋กœ๋“œ const loadDashboards = async () => { try { setLoading(true); - setError(null); const result = await dashboardApi.getMyDashboards({ search: searchTerm }); setDashboards(result.dashboards); } catch (err) { console.error("Failed to load dashboards:", err); - setError("๋Œ€์‹œ๋ณด๋“œ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + toast({ + title: "์˜ค๋ฅ˜", + description: "๋Œ€์‹œ๋ณด๋“œ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", + variant: "destructive", + }); } finally { setLoading(false); } @@ -63,6 +58,7 @@ export default function DashboardListPage() { useEffect(() => { loadDashboards(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm]); // ๋Œ€์‹œ๋ณด๋“œ ์‚ญ์ œ ํ™•์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ @@ -79,37 +75,48 @@ export default function DashboardListPage() { await dashboardApi.deleteDashboard(deleteTarget.id); setDeleteDialogOpen(false); setDeleteTarget(null); - setSuccessMessage("๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - setSuccessDialogOpen(true); + toast({ + title: "์„ฑ๊ณต", + description: "๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + }); loadDashboards(); } catch (err) { console.error("Failed to delete dashboard:", err); setDeleteDialogOpen(false); - setError("๋Œ€์‹œ๋ณด๋“œ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + toast({ + title: "์˜ค๋ฅ˜", + description: "๋Œ€์‹œ๋ณด๋“œ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", + variant: "destructive", + }); } }; // ๋Œ€์‹œ๋ณด๋“œ ๋ณต์‚ฌ const handleCopy = async (dashboard: Dashboard) => { try { - // ์ „์ฒด ๋Œ€์‹œ๋ณด๋“œ ์ •๋ณด(์š”์†Œ ํฌํ•จ)๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ const fullDashboard = await dashboardApi.getDashboard(dashboard.id); - const newDashboard = await dashboardApi.createDashboard({ + await dashboardApi.createDashboard({ title: `${fullDashboard.title} (๋ณต์‚ฌ๋ณธ)`, description: fullDashboard.description, elements: fullDashboard.elements || [], isPublic: false, tags: fullDashboard.tags, category: fullDashboard.category, - settings: (fullDashboard as any).settings, // ํ•ด์ƒ๋„์™€ ๋ฐฐ๊ฒฝ์ƒ‰ ์„ค์ •๋„ ๋ณต์‚ฌ + settings: fullDashboard.settings as { resolution?: string; backgroundColor?: string }, + }); + toast({ + title: "์„ฑ๊ณต", + description: "๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", }); - setSuccessMessage("๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); - setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to copy dashboard:", err); - setError("๋Œ€์‹œ๋ณด๋“œ ๋ณต์‚ฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + toast({ + title: "์˜ค๋ฅ˜", + description: "๋Œ€์‹œ๋ณด๋“œ ๋ณต์‚ฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", + variant: "destructive", + }); } }; @@ -119,120 +126,125 @@ export default function DashboardListPage() { year: "numeric", month: "2-digit", day: "2-digit", - hour: "2-digit", - minute: "2-digit", }); }; - if (loading) { - return ( -
-
-
๋กœ๋”ฉ ์ค‘...
-
๋Œ€์‹œ๋ณด๋“œ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค
-
-
- ); - } - return ( -
-
- {/* ํ—ค๋” */} -
-

๋Œ€์‹œ๋ณด๋“œ ๊ด€๋ฆฌ

-

๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

-
- - {/* ์•ก์…˜ ๋ฐ” */} -
-
- - setSearchTerm(e.target.value)} - className="pl-9" - /> +
+
+ {/* ํŽ˜์ด์ง€ ์ œ๋ชฉ */} +
+
+

๋Œ€์‹œ๋ณด๋“œ ๊ด€๋ฆฌ

+

๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

-
- {/* ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ */} - {error && ( - -

{error}

-
- )} + {/* ๊ฒ€์ƒ‰ ๋ฐ ํ•„ํ„ฐ */} + + +
+
+ + setSearchTerm(e.target.value)} + className="w-64 pl-10" + /> +
+ +
+
+
{/* ๋Œ€์‹œ๋ณด๋“œ ๋ชฉ๋ก */} - {dashboards.length === 0 ? ( - -
- -
-

๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

-

์ฒซ ๋ฒˆ์งธ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”๋ฅผ ์‹œ์ž‘ํ•˜์„ธ์š”

- + {loading ? ( +
+
๋กœ๋”ฉ ์ค‘...
+
+ ) : dashboards.length === 0 ? ( + + +
+ +

๋“ฑ๋ก๋œ ๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค

+

์ฒซ ๋ฒˆ์งธ ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ๋ฐ์ดํ„ฐ ์‹œ๊ฐํ™”๋ฅผ ์‹œ์ž‘ํ•˜์„ธ์š”.

+ +
+
) : ( - - - - - ์ œ๋ชฉ - ์„ค๋ช… - ์ƒ์„ฑ์ผ - ์ˆ˜์ •์ผ - ์ž‘์—… - - - - {dashboards.map((dashboard) => ( - - {dashboard.title} - - {dashboard.description || "-"} - - {formatDate(dashboard.createdAt)} - {formatDate(dashboard.updatedAt)} - - - - - - - router.push(`/admin/dashboard/edit/${dashboard.id}`)} - className="gap-2" - > - - ํŽธ์ง‘ - - handleCopy(dashboard)} className="gap-2"> - - ๋ณต์‚ฌ - - handleDeleteClick(dashboard.id, dashboard.title)} - className="gap-2 text-red-600 focus:text-red-600" - > - - ์‚ญ์ œ - - - - + + +
+ + + ์ œ๋ชฉ + ์„ค๋ช… + ์ƒ์„ฑ์ผ + ์ž‘์—… - ))} - -
+ + + {dashboards.map((dashboard) => ( + + +
{dashboard.title}
+
+ + {dashboard.description || "-"} + + {formatDate(dashboard.createdAt)} + + + + + + +
+ + + +
+
+
+
+
+ ))} +
+ +
)}
@@ -241,36 +253,24 @@ export default function DashboardListPage() { - ๋Œ€์‹œ๋ณด๋“œ ์‚ญ์ œ + ๋Œ€์‹œ๋ณด๋“œ ์‚ญ์ œ ํ™•์ธ "{deleteTarget?.title}" ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? -
์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. +
+ ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.
์ทจ์†Œ - + ์‚ญ์ œ
- - {/* ์„ฑ๊ณต ๋ชจ๋‹ฌ */} - - - -
- -
- ์™„๋ฃŒ - {successMessage} -
-
- -
-
-
); } From eac43cfb31c312e798233ffc32942be298385cfe Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:28:03 +0900 Subject: [PATCH 06/21] =?UTF-8?q?more=20=EB=B2=84=ED=8A=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 97c1036c..a20c58f9 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -20,7 +20,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; -import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreVertical } from "lucide-react"; +import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreHorizontal } from "lucide-react"; /** * ๋Œ€์‹œ๋ณด๋“œ ๊ด€๋ฆฌ ํŽ˜์ด์ง€ @@ -204,7 +204,7 @@ export default function DashboardListPage() { From ec853fb45d5239c5c7352d014d3046c3b08283d0 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:33:34 +0900 Subject: [PATCH 07/21] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 47 +++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index a20c58f9..94892c0d 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -20,6 +20,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; +import { Pagination, PaginationInfo } from "@/components/common/Pagination"; import { Plus, Search, Edit, Trash2, Copy, LayoutDashboard, MoreHorizontal } from "lucide-react"; /** @@ -34,6 +35,11 @@ export default function DashboardListPage() { const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); + // ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ƒํƒœ + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); + // ๋ชจ๋‹ฌ ์ƒํƒœ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); @@ -42,8 +48,13 @@ export default function DashboardListPage() { const loadDashboards = async () => { try { setLoading(true); - const result = await dashboardApi.getMyDashboards({ search: searchTerm }); + const result = await dashboardApi.getMyDashboards({ + search: searchTerm, + page: currentPage, + limit: pageSize, + }); setDashboards(result.dashboards); + setTotalCount(result.pagination.total); } catch (err) { console.error("Failed to load dashboards:", err); toast({ @@ -59,7 +70,28 @@ export default function DashboardListPage() { useEffect(() => { loadDashboards(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm]); + }, [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) => { @@ -247,6 +279,17 @@ export default function DashboardListPage() { )} + + {/* ํŽ˜์ด์ง€๋„ค์ด์…˜ */} + {!loading && dashboards.length > 0 && ( + + )}
{/* ์‚ญ์ œ ํ™•์ธ ๋ชจ๋‹ฌ */} From 55601481d74175f64cc3ecfc7ae25e890c94dc85 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:35:45 +0900 Subject: [PATCH 08/21] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=B5=9C=EC=86=8C=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/dashboard/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 94892c0d..55f1a291 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -162,7 +162,7 @@ export default function DashboardListPage() { }; return ( -
+
{/* ํŽ˜์ด์ง€ ์ œ๋ชฉ */}
From 71111ce0727b91e89a151ebd74f656baea494ff8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:45:04 +0900 Subject: [PATCH 09/21] =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EC=9D=B4=EB=A6=84=20z-index=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/Yard3DCanvas.tsx | 3 ++- .../widgets/yard-3d/Yard3DViewer.tsx | 21 +++++++++++++------ frontend/components/layout/MainHeader.tsx | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index d55e8ad3..29c15ca9 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -7,6 +7,7 @@ import * as THREE from "three"; interface YardPlacement { id: number; + yard_layout_id?: number; material_code?: string | null; material_name?: string | null; quantity?: number | null; @@ -26,7 +27,7 @@ interface YardPlacement { interface Yard3DCanvasProps { placements: YardPlacement[]; selectedPlacementId: number | null; - onPlacementClick: (placement: YardPlacement) => void; + onPlacementClick: (placement: YardPlacement | null) => void; onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void; } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx index ead548f1..a4dab504 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx @@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react"; interface YardPlacement { id: number; - yard_layout_id: number; + yard_layout_id?: number; material_code?: string | null; material_name?: string | null; quantity?: number | null; @@ -20,12 +20,20 @@ interface YardPlacement { size_z: number; color: string; data_source_type?: string | null; - data_source_config?: any; - data_binding?: any; + data_source_config?: Record | null; + data_binding?: Record | null; status?: string; memo?: string; } +interface YardLayout { + id: number; + name: string; + description?: string; + created_at?: string; + updated_at?: string; +} + interface Yard3DViewerProps { layoutId: number; } @@ -58,13 +66,14 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) { // ์•ผ๋“œ ๋ ˆ์ด์•„์›ƒ ์ •๋ณด ์กฐํšŒ const layoutResponse = await yardLayoutApi.getLayoutById(layoutId); if (layoutResponse.success) { - setLayoutName(layoutResponse.data.name); + const layout = layoutResponse.data as YardLayout; + setLayoutName(layout.name); } // ๋ฐฐ์น˜ ๋ฐ์ดํ„ฐ ์กฐํšŒ const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId); if (placementsResponse.success) { - setPlacements(placementsResponse.data); + setPlacements(placementsResponse.data as YardPlacement[]); } else { setError("๋ฐฐ์น˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } @@ -123,7 +132,7 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) { {/* ์•ผ๋“œ ์ด๋ฆ„ (์ขŒ์ธก ์ƒ๋‹จ) */} {layoutName && ( -
+

{layoutName}

)} diff --git a/frontend/components/layout/MainHeader.tsx b/frontend/components/layout/MainHeader.tsx index cfad594e..f04dcca3 100644 --- a/frontend/components/layout/MainHeader.tsx +++ b/frontend/components/layout/MainHeader.tsx @@ -14,7 +14,7 @@ interface MainHeaderProps { */ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) { return ( -
+
{/* Left side - Side Menu + Logo */}
From 8a2aa49910ab22bfac490b2e75b26ea42a70f253 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 16:57:19 +0900 Subject: [PATCH 10/21] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=97=90=20=ED=97=A4=EB=8D=94=EB=A7=8C?= =?UTF-8?q?=ED=81=BC=20=ED=8C=A8=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index b6e0139b..81aab11b 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -401,7 +401,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { onLogout={handleLogout} /> -
+
{/* ๋ชจ๋ฐ”์ผ ์‚ฌ์ด๋“œ๋ฐ” ์˜ค๋ฒ„๋ ˆ์ด */} {sidebarOpen && isMobile && (
setSidebarOpen(false)} /> @@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { isMobile ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40" : "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 ๋ชจ๋“œ ์ „ํ™˜ ๋ฒ„ํŠผ (๊ด€๋ฆฌ์ž๋งŒ) */} {(user as ExtendedUserInfo)?.userType === "admin" && ( From d3c9a425252503e530d2c982d3480c4985c21212 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 17:14:04 +0900 Subject: [PATCH 11/21] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 22 ++-- .../admin/dashboard/ElementConfigModal.tsx | 116 +++++++++--------- .../admin/dashboard/widgets/ClockSettings.tsx | 7 +- 3 files changed, 73 insertions(+), 72 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 7b5453f9..942a2783 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -105,6 +105,8 @@ import { CalendarWidget } from "./widgets/CalendarWidget"; // ๊ธฐ์‚ฌ ๊ด€๋ฆฌ ์œ„์ ฏ ์ž„ํฌํŠธ import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; import { ListWidget } from "./widgets/ListWidget"; +import { MoreHorizontal, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; // ์•ผ๋“œ ๊ด€๋ฆฌ 3D ์œ„์ ฏ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { @@ -546,22 +548,26 @@ export function CanvasElement({
{/* ์„ค์ • ๋ฒ„ํŠผ (๊ธฐ์‚ฌ๊ด€๋ฆฌ ์œ„์ ฏ๋งŒ ์ž์ฒด ์„ค์ • UI ์‚ฌ์šฉ) */} {onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && ( - + + )} {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} - + +
diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 6aba88db..b168fb2c 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -53,9 +53,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "driver-management" || element.subtype === "work-history" || // ์ž‘์—… ์ด๋ ฅ ์œ„์ ฏ (์ฟผ๋ฆฌ ํ•„์š”) element.subtype === "transport-stats"; // ์ปค์Šคํ…€ ํ†ต๊ณ„ ์นด๋“œ ์œ„์ ฏ (์ฟผ๋ฆฌ ํ•„์š”) - + // ์ž์ฒด ๊ธฐ๋Šฅ ์œ„์ ฏ (DB ์—ฐ๊ฒฐ ๋ถˆํ•„์š”, ํ—ค๋” ์„ค์ •๋งŒ ๊ฐ€๋Šฅ) - const isSelfContainedWidget = + const isSelfContainedWidget = element.subtype === "weather" || // ๋‚ ์”จ ์œ„์ ฏ (์™ธ๋ถ€ API) element.subtype === "exchange" || // ํ™˜์œจ ์œ„์ ฏ (์™ธ๋ถ€ API) element.subtype === "calculator"; // ๊ณ„์‚ฐ๊ธฐ ์œ„์ ฏ (์ž์ฒด ๊ธฐ๋Šฅ) @@ -150,11 +150,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element if (!isOpen) return null; // ์‹œ๊ณ„, ๋‹ฌ๋ ฅ, ๋‚ ์”จ, ํ™˜์œจ, ๊ณ„์‚ฐ๊ธฐ ์œ„์ ฏ์€ ํ—ค๋” ์„ค์ •๋งŒ ๊ฐ€๋Šฅ - const isHeaderOnlyWidget = - element.type === "widget" && - (element.subtype === "clock" || - element.subtype === "calendar" || - isSelfContainedWidget); + const isHeaderOnlyWidget = + element.type === "widget" && + (element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget); // ๊ธฐ์‚ฌ๊ด€๋ฆฌ ์œ„์ ฏ์€ ์ž์ฒด ์„ค์ • UI๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ์œผ๋ฏ€๋กœ ๋ชจ๋‹ฌ ํ‘œ์‹œํ•˜์ง€ ์•Š์Œ if (element.type === "widget" && element.subtype === "driver-management") { @@ -172,7 +170,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // customTitle์ด ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); - + // showHeader๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ const isHeaderChanged = showHeader !== (element.showHeader !== false); @@ -214,13 +212,6 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element

{element.title} ์„ค์ •

-

- {isSimpleWidget - ? "๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์„ค์ •ํ•˜์„ธ์š”" - : currentStep === 1 - ? "๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์„ ํƒํ•˜์„ธ์š”" - : "์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์ฐจํŠธ๋ฅผ ์„ค์ •ํ•˜์„ธ์š”"} -

{/* ํ—ค๋” ํ‘œ์‹œ ์˜ต์…˜ */} @@ -251,7 +244,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element id="showHeader" checked={showHeader} 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" />
)}
)} @@ -376,4 +373,3 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
); } - diff --git a/frontend/components/admin/dashboard/widgets/ClockSettings.tsx b/frontend/components/admin/dashboard/widgets/ClockSettings.tsx index dd28c3af..43a452fe 100644 --- a/frontend/components/admin/dashboard/widgets/ClockSettings.tsx +++ b/frontend/components/admin/dashboard/widgets/ClockSettings.tsx @@ -44,9 +44,9 @@ export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
{[ - { value: "digital", label: "๋””์ง€ํ„ธ", icon: "๐Ÿ”ข" }, - { value: "analog", label: "์•„๋‚ ๋กœ๊ทธ", icon: "๐Ÿ•" }, - { value: "both", label: "๋‘˜ ๋‹ค", icon: "โฐ" }, + { value: "digital", label: "๋””์ง€ํ„ธ" }, + { value: "analog", label: "์•„๋‚ ๋กœ๊ทธ" }, + { value: "both", label: "๋‘˜ ๋‹ค" }, ].map((style) => ( ))} From 2305b8dfaeae808c42bc8c301e78efffe1a489db Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 17:16:03 +0900 Subject: [PATCH 12/21] =?UTF-8?q?=EC=9A=94=EC=86=8C=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=EC=9D=84=20=ED=88=AC=EB=AA=85=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/CanvasElement.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 942a2783..a4b0fc6f 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -543,7 +543,7 @@ export function CanvasElement({ onMouseDown={handleMouseDown} > {/* ํ—ค๋” */} -
+
{element.customTitle || element.title}
{/* ์„ค์ • ๋ฒ„ํŠผ (๊ธฐ์‚ฌ๊ด€๋ฆฌ ์œ„์ ฏ๋งŒ ์ž์ฒด ์„ค์ • UI ์‚ฌ์šฉ) */} From 8c18555305ca40d500ba40ed1a527a015114c544 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 17:18:28 +0900 Subject: [PATCH 13/21] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/charts/ChartRenderer.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 092c2b0a..9a5a51a6 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -51,7 +51,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char if (element.dataSource.queryParams) { Object.entries(element.dataSource.queryParams).forEach(([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); return () => clearInterval(interval); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ element.dataSource?.query, element.dataSource?.connectionType, element.dataSource?.externalConnectionId, element.dataSource?.refreshInterval, + element.dataSource?.type, + element.dataSource?.endpoint, + element.dataSource?.jsonPath, element.chartConfig, data, ]); @@ -201,9 +205,7 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char return (
-
๐Ÿ“Š
๋ฐ์ดํ„ฐ๋ฅผ ์„ค์ •ํ•ด์ฃผ์„ธ์š”
-
โš™๏ธ ๋ฒ„ํŠผ์„ ํด๋ฆญํ•˜์—ฌ ์„ค์ •
); From 10d112bd6907e0e54229a80191272cebac3ba44a Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 21 Oct 2025 17:32:54 +0900 Subject: [PATCH 14/21] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenDesigner.tsx | 253 ++++--- .../config-panels/ButtonConfigPanel-fixed.tsx | 625 ++++++++++++++++++ .../config-panels/ButtonConfigPanel.tsx | 202 ++---- .../screen/panels/DetailSettingsPanel.tsx | 3 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 3 +- .../button-primary/ButtonPrimaryComponent.tsx | 10 +- 6 files changed, 862 insertions(+), 234 deletions(-) create mode 100644 frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 022b53c1..1b9ec180 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -420,37 +420,39 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD timestamp: new Date().toISOString(), }); - const targetComponent = layout.components.find((comp) => comp.id === componentId); - const isLayoutComponent = targetComponent?.type === "layout"; + // ๐Ÿ”ฅ ํ•จ์ˆ˜ํ˜• ์—…๋ฐ์ดํŠธ๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ์ตœ์‹  layout ์‚ฌ์šฉ + setLayout((prevLayout) => { + const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); + const isLayoutComponent = targetComponent?.type === "layout"; - // ๋ ˆ์ด์•„์›ƒ ์ปดํฌ๋„ŒํŠธ์˜ ์œ„์น˜๊ฐ€ ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒฝ์šฐ ์กด์— ์†ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค๋„ ํ•จ๊ป˜ ์ด๋™ - const positionDelta = { x: 0, y: 0 }; - if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { - const oldPosition = targetComponent.position; - let newPosition = { ...oldPosition }; + // ๋ ˆ์ด์•„์›ƒ ์ปดํฌ๋„ŒํŠธ์˜ ์œ„์น˜๊ฐ€ ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒฝ์šฐ ์กด์— ์†ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค๋„ ํ•จ๊ป˜ ์ด๋™ + const positionDelta = { x: 0, y: 0 }; + if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { + const oldPosition = targetComponent.position; + let newPosition = { ...oldPosition }; - if (path === "position.x") { - newPosition.x = value; - positionDelta.x = value - oldPosition.x; - } else if (path === "position.y") { - newPosition.y = value; - positionDelta.y = value - oldPosition.y; - } else if (path === "position") { - newPosition = value; - positionDelta.x = value.x - oldPosition.x; - positionDelta.y = value.y - oldPosition.y; + if (path === "position.x") { + newPosition.x = value; + positionDelta.x = value - oldPosition.x; + } else if (path === "position.y") { + newPosition.y = value; + positionDelta.y = value - oldPosition.y; + } else if (path === "position") { + newPosition = value; + positionDelta.x = value.x - oldPosition.x; + positionDelta.y = value.y - oldPosition.y; + } + + console.log("๐Ÿ“ ๋ ˆ์ด์•„์›ƒ ์ด๋™ ๊ฐ์ง€:", { + layoutId: componentId, + oldPosition, + newPosition, + positionDelta, + }); } - console.log("๐Ÿ“ ๋ ˆ์ด์•„์›ƒ ์ด๋™ ๊ฐ์ง€:", { - layoutId: componentId, - oldPosition, - newPosition, - positionDelta, - }); - } - - const pathParts = path.split("."); - const updatedComponents = layout.components.map((comp) => { + const pathParts = path.split("."); + const updatedComponents = prevLayout.components.map((comp) => { if (comp.id !== componentId) { // ๋ ˆ์ด์•„์›ƒ ์ด๋™ ์‹œ ์กด์— ์†ํ•œ ์ปดํฌ๋„ŒํŠธ๋“ค๋„ ํ•จ๊ป˜ ์ด๋™ if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { @@ -480,22 +482,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // ์ค‘์ฒฉ ๊ฒฝ๋กœ๋ฅผ ๊ณ ๋ คํ•œ ์•ˆ์ „ํ•œ ๋ณต์‚ฌ const newComp = { ...comp }; + console.log("๐Ÿ” ์—…๋ฐ์ดํŠธ ์ „ ์ƒํƒœ:", { + path, + value, + "๊ธฐ์กด componentConfig": newComp.componentConfig, + "๊ธฐ์กด action": (newComp as any).componentConfig?.action, + }); + // ๊ฒฝ๋กœ๋ฅผ ๋”ฐ๋ผ ๋‚ด๋ ค๊ฐ€๋ฉด์„œ ๊ฐ ๋ ˆ๋ฒจ์„ ์ƒˆ ๊ฐ์ฒด๋กœ ๋ณต์‚ฌ let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { const key = pathParts[i]; + console.log(`๐Ÿ” ๊ฒฝ๋กœ ํƒ์ƒ‰ [${i}]: key="${key}", current[key]=`, current[key]); + // ๋‹ค์Œ ๋ ˆ๋ฒจ์ด ์—†๊ฑฐ๋‚˜ ๊ฐ์ฒด๊ฐ€ ์•„๋‹ˆ๋ฉด ์ƒˆ ๊ฐ์ฒด ์ƒ์„ฑ if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { + console.log(`๐Ÿ†• ์ƒˆ ๊ฐ์ฒด ์ƒ์„ฑ: ${key}`); current[key] = {}; } else { // ๊ธฐ์กด ๊ฐ์ฒด๋ฅผ ๋ณต์‚ฌํ•˜์—ฌ ๋ถˆ๋ณ€์„ฑ ์œ ์ง€ + console.log(`๐Ÿ“‹ ๊ธฐ์กด ๊ฐ์ฒด ๋ณต์‚ฌ: ${key}`, { ...current[key] }); current[key] = { ...current[key] }; } current = current[key]; } // ์ตœ์ข… ๊ฐ’ ์„ค์ • - current[pathParts[pathParts.length - 1]] = value; + const finalKey = pathParts[pathParts.length - 1]; + console.log(`โœ๏ธ ์ตœ์ข… ๊ฐ’ ์„ค์ •: ${finalKey} = ${value}`); + current[finalKey] = value; console.log("โœ… ์ปดํฌ๋„ŒํŠธ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ:", { componentId, @@ -551,25 +566,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // ํฌ๊ธฐ ๋ณ€๊ฒฝ ์‹œ ๊ฒฉ์ž ์Šค๋ƒ… ์ ์šฉ (๊ทธ๋ฃน ์ปดํฌ๋„ŒํŠธ ์ œ์™ธ) if ( (path === "size.width" || path === "size.height") && - layout.gridSettings?.snapToGrid && + prevLayout.gridSettings?.snapToGrid && gridInfo && newComp.type !== "group" ) { // ํ˜„์žฌ ํ•ด์ƒ๋„์— ๋งž๋Š” ๊ฒฉ์ž ์ •๋ณด๋กœ ์Šค๋ƒ… ์ ์šฉ const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, + columns: prevLayout.gridSettings.columns, + gap: prevLayout.gridSettings.gap, + padding: prevLayout.gridSettings.padding, + snapToGrid: prevLayout.gridSettings.snapToGrid || false, }); - const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, layout.gridSettings as GridUtilSettings); + const snappedSize = snapSizeToGrid(newComp.size, currentGridInfo, prevLayout.gridSettings as GridUtilSettings); newComp.size = snappedSize; // ํฌ๊ธฐ ๋ณ€๊ฒฝ ์‹œ gridColumns๋„ ์ž๋™ ์กฐ์ • const adjustedColumns = adjustGridColumnsFromSize( newComp, currentGridInfo, - layout.gridSettings as GridUtilSettings, + prevLayout.gridSettings as GridUtilSettings, ); if (newComp.gridColumns !== adjustedColumns) { newComp.gridColumns = adjustedColumns; @@ -582,19 +597,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } // gridColumns ๋ณ€๊ฒฝ ์‹œ ํฌ๊ธฐ๋ฅผ ๊ฒฉ์ž์— ๋งž๊ฒŒ ์ž๋™ ์กฐ์ • - if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") { + if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, + columns: prevLayout.gridSettings.columns, + gap: prevLayout.gridSettings.gap, + padding: prevLayout.gridSettings.padding, + snapToGrid: prevLayout.gridSettings.snapToGrid || false, }); // gridColumns์— ๋งž๋Š” ์ •ํ™•ํ•œ ๋„ˆ๋น„ ๊ณ„์‚ฐ const newWidth = calculateWidthFromColumns( newComp.gridColumns, currentGridInfo, - layout.gridSettings as GridUtilSettings, + prevLayout.gridSettings as GridUtilSettings, ); newComp.size = { ...newComp.size, @@ -699,52 +714,71 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } - return newComp; - }); + return newComp; + }); - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); + // ๐Ÿ”ฅ ์ƒˆ๋กœ์šด layout ์ƒ์„ฑ + const newLayout = { ...prevLayout, components: updatedComponents }; + + console.log("๐Ÿ”„ setLayout ์‹คํ–‰:", { + componentId, + path, + value, + ์—…๋ฐ์ดํŠธ๋œ์ปดํฌ๋„ŒํŠธ: updatedComponents.find((c) => c.id === componentId), + }); + + saveToHistory(newLayout); + + // selectedComponent๊ฐ€ ์—…๋ฐ์ดํŠธ๋œ ์ปดํฌ๋„ŒํŠธ์™€ ๊ฐ™๋‹ค๋ฉด selectedComponent๋„ ์—…๋ฐ์ดํŠธ + setSelectedComponent((prevSelected) => { + if (prevSelected && prevSelected.id === componentId) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); + if (updatedSelectedComponent) { + // ๐Ÿ”ง ์™„์ „ํžˆ ์ƒˆ๋กœ์šด ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด์„œ React๊ฐ€ ๋ณ€๊ฒฝ์„ ๊ฐ์ง€ํ•˜๋„๋ก ํ•จ + const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); + + console.log("๐Ÿ”„ selectedComponent ๋™๊ธฐํ™”:", { + componentId, + path, + oldAction: (prevSelected as any).componentConfig?.action, + newAction: (newSelectedComponent as any).componentConfig?.action, + oldColumnsCount: + prevSelected.type === "datatable" ? (prevSelected as any).columns?.length : "N/A", + newColumnsCount: + newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).columns?.length : "N/A", + oldFiltersCount: + prevSelected.type === "datatable" ? (prevSelected as any).filters?.length : "N/A", + newFiltersCount: + newSelectedComponent.type === "datatable" ? (newSelectedComponent as any).filters?.length : "N/A", + timestamp: new Date().toISOString(), + }); + return newSelectedComponent; + } + } + return prevSelected; + }); - // selectedComponent๊ฐ€ ์—…๋ฐ์ดํŠธ๋œ ์ปดํฌ๋„ŒํŠธ์™€ ๊ฐ™๋‹ค๋ฉด selectedComponent๋„ ์—…๋ฐ์ดํŠธ - if (selectedComponent && selectedComponent.id === componentId) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedSelectedComponent) { - console.log("๐Ÿ”„ selectedComponent ๋™๊ธฐํ™”:", { + // webTypeConfig ์—…๋ฐ์ดํŠธ ํ›„ ๋ ˆ์ด์•„์›ƒ ์ƒํƒœ ํ™•์ธ + if (path === "webTypeConfig") { + const updatedComponent = newLayout.components.find((c) => c.id === componentId); + console.log("๐Ÿ”„ ๋ ˆ์ด์•„์›ƒ ์—…๋ฐ์ดํŠธ ํ›„ ์ปดํฌ๋„ŒํŠธ ์ƒํƒœ:", { componentId, - path, - oldColumnsCount: - selectedComponent.type === "datatable" ? (selectedComponent as any).columns?.length : "N/A", - newColumnsCount: - updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).columns?.length : "N/A", - oldFiltersCount: - selectedComponent.type === "datatable" ? (selectedComponent as any).filters?.length : "N/A", - newFiltersCount: - updatedSelectedComponent.type === "datatable" ? (updatedSelectedComponent as any).filters?.length : "N/A", + updatedComponent: updatedComponent + ? { + id: updatedComponent.id, + type: updatedComponent.type, + webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, + } + : null, + layoutComponentsCount: newLayout.components.length, timestamp: new Date().toISOString(), }); - setSelectedComponent(updatedSelectedComponent); } - } - // webTypeConfig ์—…๋ฐ์ดํŠธ ํ›„ ๋ ˆ์ด์•„์›ƒ ์ƒํƒœ ํ™•์ธ - if (path === "webTypeConfig") { - const updatedComponent = newLayout.components.find((c) => c.id === componentId); - console.log("๐Ÿ”„ ๋ ˆ์ด์•„์›ƒ ์—…๋ฐ์ดํŠธ ํ›„ ์ปดํฌ๋„ŒํŠธ ์ƒํƒœ:", { - componentId, - updatedComponent: updatedComponent - ? { - id: updatedComponent.id, - type: updatedComponent.type, - webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, - } - : null, - layoutComponentsCount: newLayout.components.length, - timestamp: new Date().toISOString(), - }); - } + return newLayout; + }); }, - [layout, gridInfo, saveToHistory], + [gridInfo, saveToHistory], // ๐Ÿ”ง layout, selectedComponent ์ œ๊ฑฐ! ); // ์ปดํฌ๋„ŒํŠธ ์‹œ์Šคํ…œ ์ดˆ๊ธฐํ™” @@ -1294,11 +1328,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD components: updatedComponents, screenResolution: screenResolution, }; + // ๐Ÿ” ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ๋“ค์˜ action.type ํ™•์ธ + const buttonComponents = layoutWithResolution.components.filter( + (c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary" + ); console.log("๐Ÿ’พ ์ €์žฅ ์‹œ์ž‘:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length, gridSettings: layoutWithResolution.gridSettings, screenResolution: layoutWithResolution.screenResolution, + buttonComponents: buttonComponents.map((c: any) => ({ + id: c.id, + type: c.type, + text: c.componentConfig?.text, + actionType: c.componentConfig?.action?.type, + fullAction: c.componentConfig?.action, + })), }); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); @@ -2127,7 +2172,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // ์ƒˆ ์ปดํฌ๋„ŒํŠธ ์„ ํƒ setSelectedComponent(newComponent); - openPanel("properties"); + // ๐Ÿ”ง ํ…Œ์ด๋ธ” ํŒจ๋„ ์œ ์ง€๋ฅผ ์œ„ํ•ด ์ž๋™ ์†์„ฑ ํŒจ๋„ ์—ด๊ธฐ ๋น„ํ™œ์„ฑํ™” + // openPanel("properties"); toast.success(`${component.name} ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`); }, @@ -2610,8 +2656,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD saveToHistory(newLayout); setSelectedComponent(newComponent); - // ์†์„ฑ ํŒจ๋„ ์ž๋™ ์—ด๊ธฐ - openPanel("properties"); + // ๐Ÿ”ง ํ…Œ์ด๋ธ” ํŒจ๋„ ์œ ์ง€๋ฅผ ์œ„ํ•ด ์ž๋™ ์†์„ฑ ํŒจ๋„ ์—ด๊ธฐ ๋น„ํ™œ์„ฑํ™” + // openPanel("properties"); } catch (error) { // console.error("๋“œ๋กญ ์ฒ˜๋ฆฌ ์‹คํŒจ:", error); } @@ -2674,47 +2720,66 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return; } + // ๐Ÿ”ง layout.components์—์„œ ์ตœ์‹  ๋ฒ„์ „์˜ ์ปดํฌ๋„ŒํŠธ ์ฐพ๊ธฐ + const latestComponent = layout.components.find((c) => c.id === component.id); + if (!latestComponent) { + console.warn("โš ๏ธ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค:", component.id); + return; + } + + console.log("๐Ÿ” ์ปดํฌ๋„ŒํŠธ ํด๋ฆญ ์‹œ ์ตœ์‹  ๋ฒ„์ „ ํ™•์ธ:", { + componentId: component.id, + ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ๋ฐ›์€๋ฒ„์ „: { + actionType: (component as any).componentConfig?.action?.type, + fullAction: (component as any).componentConfig?.action, + }, + layout์—์„œ์ฐพ์€์ตœ์‹ ๋ฒ„์ „: { + actionType: (latestComponent as any).componentConfig?.action?.type, + fullAction: (latestComponent as any).componentConfig?.action, + }, + }); + const isShiftPressed = event?.shiftKey || false; const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; - const isGroupContainer = component.type === "group"; + const isGroupContainer = latestComponent.type === "group"; if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { // ๋‹ค์ค‘ ์„ ํƒ ๋ชจ๋“œ if (isGroupContainer) { // ๊ทธ๋ฃน ์ปจํ…Œ์ด๋„ˆ๋Š” ๋‹จ์ผ ์„ ํƒ์œผ๋กœ ์ฒ˜๋ฆฌ - handleComponentSelect(component); + handleComponentSelect(latestComponent); // ๐Ÿ”ง ์ตœ์‹  ๋ฒ„์ „ ์‚ฌ์šฉ setGroupState((prev) => ({ ...prev, - selectedComponents: [component.id], + selectedComponents: [latestComponent.id], isGrouping: false, })); return; } - const isSelected = groupState.selectedComponents.includes(component.id); + const isSelected = groupState.selectedComponents.includes(latestComponent.id); setGroupState((prev) => ({ ...prev, selectedComponents: isSelected - ? prev.selectedComponents.filter((id) => id !== component.id) - : [...prev.selectedComponents, component.id], + ? prev.selectedComponents.filter((id) => id !== latestComponent.id) + : [...prev.selectedComponents, latestComponent.id], })); // ๋งˆ์ง€๋ง‰ ์„ ํƒ๋œ ์ปดํฌ๋„ŒํŠธ๋ฅผ selectedComponent๋กœ ์„ค์ • if (!isSelected) { - // console.log("๐ŸŽฏ ์ปดํฌ๋„ŒํŠธ ์„ ํƒ (๋‹ค์ค‘ ๋ชจ๋“œ):", component.id); - handleComponentSelect(component); + // console.log("๐ŸŽฏ ์ปดํฌ๋„ŒํŠธ ์„ ํƒ (๋‹ค์ค‘ ๋ชจ๋“œ):", latestComponent.id); + handleComponentSelect(latestComponent); // ๐Ÿ”ง ์ตœ์‹  ๋ฒ„์ „ ์‚ฌ์šฉ } } else { // ๋‹จ์ผ ์„ ํƒ ๋ชจ๋“œ - // console.log("๐ŸŽฏ ์ปดํฌ๋„ŒํŠธ ์„ ํƒ (๋‹จ์ผ ๋ชจ๋“œ):", component.id); - handleComponentSelect(component); + // console.log("๐ŸŽฏ ์ปดํฌ๋„ŒํŠธ ์„ ํƒ (๋‹จ์ผ ๋ชจ๋“œ):", latestComponent.id); + handleComponentSelect(latestComponent); // ๐Ÿ”ง ์ตœ์‹  ๋ฒ„์ „ ์‚ฌ์šฉ setGroupState((prev) => ({ ...prev, - selectedComponents: [component.id], + selectedComponents: [latestComponent.id], })); } }, - [handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag], + [handleComponentSelect, groupState.isGrouping, groupState.selectedComponents, dragState.justFinishedDrag, layout.components], ); // ์ปดํฌ๋„ŒํŠธ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx new file mode 100644 index 00000000..a70a0633 --- /dev/null +++ b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx @@ -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 = ({ 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([]); + 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 ( +
+
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, text: newValue })); + onUpdateProperty("componentConfig.text", newValue); + }} + placeholder="๋ฒ„ํŠผ ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {/* ๋ชจ๋‹ฌ ์—ด๊ธฐ ์•ก์…˜ ์„ค์ • */} + {localSelects.actionType === "modal" && ( +
+

๋ชจ๋‹ฌ ์„ค์ •

+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); + onUpdateProperty("componentConfig.action.modalTitle", newValue); + }} + /> +
+ +
+ + +
+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
ํ™”๋ฉด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
; + } + if (filteredScreens.length === 0) { + return
๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+
+
+ )} + + {/* ์ˆ˜์ • ์•ก์…˜ ์„ค์ • */} + {localSelects.actionType === "edit" && ( +
+

์ˆ˜์ • ์„ค์ •

+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
ํ™”๋ฉด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
; + } + if (filteredScreens.length === 0) { + return
๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ ์„ ํƒ๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์ด ํผ ํ™”๋ฉด์— ์ž๋™์œผ๋กœ ๋กœ๋“œ๋˜์–ด ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค +

+
+ +
+ + +
+ + {localSelects.editMode === "modal" && ( + <> +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); + onUpdateProperty("componentConfig.action.editModalTitle", newValue); + onUpdateProperty("webTypeConfig.editModalTitle", newValue); + }} + /> +

๋น„์›Œ๋‘๋ฉด ๊ธฐ๋ณธ ์ œ๋ชฉ์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); + onUpdateProperty("componentConfig.action.editModalDescription", newValue); + onUpdateProperty("webTypeConfig.editModalDescription", newValue); + }} + /> +

๋น„์›Œ๋‘๋ฉด ์„ค๋ช…์ด ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค

+
+ +
+ + +
+ + )} +
+ )} + + {/* ํŽ˜์ด์ง€ ์ด๋™ ์•ก์…˜ ์„ค์ • */} + {localSelects.actionType === "navigate" && ( +
+

ํŽ˜์ด์ง€ ์ด๋™ ์„ค์ •

+ +
+ + + + + + +
+
+ + setNavSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(navSearchTerm); + if (screensLoading) { + return
ํ™”๋ฉด ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...
; + } + if (filteredScreens.length === 0) { + return
๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setNavScreenOpen(false); + setNavSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ ์„ ํƒํ•œ ํ™”๋ฉด์œผ๋กœ /screens/{"{"}ํ™”๋ฉดID{"}"} ํ˜•ํƒœ๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค +

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); + onUpdateProperty("componentConfig.action.targetUrl", newValue); + }} + /> +

URL์„ ์ž…๋ ฅํ•˜๋ฉด ํ™”๋ฉด ์„ ํƒ๋ณด๋‹ค ์šฐ์„  ์ ์šฉ๋ฉ๋‹ˆ๋‹ค

+
+
+ )} + + {/* ๐Ÿ”ฅ NEW: ์ œ์–ด๊ด€๋ฆฌ ๊ธฐ๋Šฅ ์„น์…˜ */} +
+
+

๐Ÿ”ง ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ

+

๋ฒ„ํŠผ ์•ก์…˜๊ณผ ํ•จ๊ป˜ ์‹คํ–‰๋  ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค

+
+ + +
+
+ ); +}; + diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 2cb1e3ce..dac32163 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -26,25 +26,24 @@ interface ScreenOption { } export const ButtonConfigPanel: React.FC = ({ component, onUpdateProperty }) => { + console.log("๐ŸŽจ ButtonConfigPanel ๋ Œ๋”๋ง:", { + componentId: component.id, + "component.componentConfig?.action?.type": component.componentConfig?.action?.type, + }); + + // ๐Ÿ”ง component์—์„œ ์ง์ ‘ ์ฝ๊ธฐ (useMemo ์ œ๊ฑฐ) const config = component.componentConfig || {}; + const currentAction = component.componentConfig?.action || {}; // ๋กœ์ปฌ ์ƒํƒœ ๊ด€๋ฆฌ (์‹ค์‹œ๊ฐ„ ์ž…๋ ฅ ๋ฐ˜์˜) const [localInputs, setLocalInputs] = useState({ - text: config.text || "๋ฒ„ํŠผ", + 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 || "default", - actionType: config.action?.type || "save", - modalSize: config.action?.modalSize || "md", - editMode: config.action?.editMode || "modal", - }); - const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); const [modalScreenOpen, setModalScreenOpen] = useState(false); @@ -52,44 +51,27 @@ export const ButtonConfigPanel: React.FC = ({ component, const [modalSearchTerm, setModalSearchTerm] = useState(""); const [navSearchTerm, setNavSearchTerm] = useState(""); - // ์ปดํฌ๋„ŒํŠธ ๋ณ€๊ฒฝ ์‹œ ๋กœ์ปฌ ์ƒํƒœ ๋™๊ธฐํ™” + // ์ปดํฌ๋„ŒํŠธ prop ๋ณ€๊ฒฝ ์‹œ ๋กœ์ปฌ ์ƒํƒœ ๋™๊ธฐํ™” (Input๋งŒ) useEffect(() => { - setLocalInputs({ - text: config.text || "๋ฒ„ํŠผ", - modalTitle: config.action?.modalTitle || "", - editModalTitle: config.action?.editModalTitle || "", - editModalDescription: config.action?.editModalDescription || "", - targetUrl: config.action?.targetUrl || "", - }); + const latestConfig = component.componentConfig || {}; + const latestAction = latestConfig.action || {}; - setLocalSelects({ - variant: config.variant || "default", - size: config.size || "default", - actionType: config.action?.type || "save", - modalSize: config.action?.modalSize || "md", - editMode: config.action?.editMode || "modal", + setLocalInputs({ + text: latestConfig.text !== undefined ? latestConfig.text : "๋ฒ„ํŠผ", + modalTitle: latestAction.modalTitle || "", + editModalTitle: latestAction.editModalTitle || "", + editModalDescription: latestAction.editModalDescription || "", + targetUrl: latestAction.targetUrl || "", }); - }, [ - config.text, - 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, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [component.id]); // ํ™”๋ฉด ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ useEffect(() => { const fetchScreens = async () => { try { setScreensLoading(true); - // console.log("๐Ÿ” ํ™”๋ฉด ๋ชฉ๋ก API ํ˜ธ์ถœ ์‹œ์ž‘"); const response = await apiClient.get("/screen-management/screens"); - // console.log("โœ… ํ™”๋ฉด ๋ชฉ๋ก API ์‘๋‹ต:", response.data); if (response.data.success && Array.isArray(response.data.data)) { const screenList = response.data.data.map((screen: any) => ({ @@ -98,7 +80,6 @@ export const ButtonConfigPanel: React.FC = ({ component, description: screen.description, })); setScreens(screenList); - // console.log("โœ… ํ™”๋ฉด ๋ชฉ๋ก ์„ค์ • ์™„๋ฃŒ:", screenList.length, "๊ฐœ"); } } catch (error) { // console.error("โŒ ํ™”๋ฉด ๋ชฉ๋ก ๋กœ๋”ฉ ์‹คํŒจ:", error); @@ -120,13 +101,13 @@ export const ButtonConfigPanel: React.FC = ({ component, ); }; - console.log("๐Ÿ”ง config-panels/ButtonConfigPanel ๋ Œ๋”๋ง:", { - component, - config, - action: config.action, - actionType: config.action?.type, - screensCount: screens.length, - }); + // console.log("๐Ÿ”ง config-panels/ButtonConfigPanel ๋ Œ๋”๋ง:", { + // component, + // config, + // action: config.action, + // actionType: config.action?.type, + // screensCount: screens.length, + // }); return (
@@ -147,9 +128,8 @@ export const ButtonConfigPanel: React.FC = ({ component,
{ - setLocalSelects((prev) => ({ ...prev, size: value })); onUpdateProperty("componentConfig.size", value); }} > - + - ์ž‘์Œ (Small) - ๊ธฐ๋ณธ (Default) - ํผ (Large) + ์ž‘์Œ (Small) + ๊ธฐ๋ณธ (Default) + ํผ (Large)
@@ -191,28 +170,23 @@ export const ButtonConfigPanel: React.FC = ({ component,
{ - setLocalSelects((prev) => ({ ...prev, modalSize: value })); - onUpdateProperty("componentConfig.action", { - ...config.action, - modalSize: value, - }); + onUpdateProperty("componentConfig.action.modalSize", value); }} > @@ -301,7 +268,6 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */}
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ */}
{(() => { const filteredScreens = filterScreens(modalSearchTerm); @@ -326,10 +291,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`modal-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} @@ -356,7 +318,7 @@ export const ButtonConfigPanel: React.FC = ({ component, )} {/* ์ˆ˜์ • ์•ก์…˜ ์„ค์ • */} - {localSelects.actionType === "edit" && ( + {(component.componentConfig?.action?.type || "save") === "edit" && (

์ˆ˜์ • ์„ค์ •

@@ -380,7 +342,6 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */}
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ */}
{(() => { const filteredScreens = filterScreens(modalSearchTerm); @@ -405,10 +365,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`edit-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setModalScreenOpen(false); setModalSearchTerm(""); }} @@ -438,13 +395,9 @@ export const ButtonConfigPanel: React.FC = ({ component,
- {localSelects.editMode === "modal" && ( + {(component.componentConfig?.action?.editMode || "modal") === "modal" && ( <>
@@ -469,11 +422,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalTitle: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - editModalTitle: newValue, - }); - // webTypeConfig์—๋„ ์ €์žฅ + onUpdateProperty("componentConfig.action.editModalTitle", newValue); onUpdateProperty("webTypeConfig.editModalTitle", newValue); }} /> @@ -489,11 +438,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, editModalDescription: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - editModalDescription: newValue, - }); - // webTypeConfig์—๋„ ์ €์žฅ + onUpdateProperty("componentConfig.action.editModalDescription", newValue); onUpdateProperty("webTypeConfig.editModalDescription", newValue); }} /> @@ -503,13 +448,9 @@ export const ButtonConfigPanel: React.FC = ({ component,
= ({ component, className="border-0 p-0 focus-visible:ring-0" />
- {/* ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ */}
{(() => { const filteredScreens = filterScreens(navSearchTerm); @@ -579,10 +518,7 @@ export const ButtonConfigPanel: React.FC = ({ component, key={`navigate-screen-${screen.id}-${index}`} className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100" onClick={() => { - onUpdateProperty("componentConfig.action", { - ...config.action, - targetScreenId: screen.id, - }); + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); setNavScreenOpen(false); setNavSearchTerm(""); }} @@ -618,10 +554,7 @@ export const ButtonConfigPanel: React.FC = ({ component, onChange={(e) => { const newValue = e.target.value; setLocalInputs((prev) => ({ ...prev, targetUrl: newValue })); - onUpdateProperty("componentConfig.action", { - ...config.action, - targetUrl: newValue, - }); + onUpdateProperty("componentConfig.action.targetUrl", newValue); }} />

URL์„ ์ž…๋ ฅํ•˜๋ฉด ํ™”๋ฉด ์„ ํƒ๋ณด๋‹ค ์šฐ์„  ์ ์šฉ๋ฉ๋‹ˆ๋‹ค

@@ -641,3 +574,4 @@ export const ButtonConfigPanel: React.FC = ({ component,
); }; + diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index 84fdf251..9540c21b 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -822,7 +822,8 @@ export const DetailSettingsPanel: React.FC = ({ case "button": case "button-primary": case "button-secondary": - return ; + // ๐Ÿ”ง component.id๋งŒ key๋กœ ์‚ฌ์šฉ (unmount ๋ฐฉ์ง€) + return ; case "card": return ; diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6c325ac8..71172d4d 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -123,7 +123,8 @@ export const UnifiedPropertiesPanel: React.FC = ({ case "button": case "button-primary": case "button-secondary": - return ; + // ๐Ÿ”ง component.id๋งŒ key๋กœ ์‚ฌ์šฉ (unmount ๋ฐฉ์ง€) + return ; case "card": return ; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 80d3a001..0b0255dc 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -491,7 +491,8 @@ export const ButtonPrimaryComponent: React.FC = ({ ? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)" : `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`, 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", cursor: componentConfig.disabled ? "not-allowed" : "pointer", outline: "none", @@ -499,10 +500,10 @@ export const ButtonPrimaryComponent: React.FC = ({ display: "flex", alignItems: "center", justifyContent: "center", - padding: "0 1rem", + // ๐Ÿ”ง ํฌ๊ธฐ์— ๋”ฐ๋ฅธ ํŒจ๋”ฉ ์กฐ์ • + padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem", margin: "0", 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`, // isInteractive ๋ชจ๋“œ์—์„œ๋Š” ์‚ฌ์šฉ์ž ์Šคํƒ€์ผ ์šฐ์„  ์ ์šฉ ...(isInteractive && component.style ? component.style : {}), @@ -511,7 +512,8 @@ export const ButtonPrimaryComponent: React.FC = ({ onDragStart={onDragStart} onDragEnd={onDragEnd} > - {processedConfig.text || component.label || "๋ฒ„ํŠผ"} + {/* ๐Ÿ”ง ๋นˆ ๋ฌธ์ž์—ด๋„ ํ—ˆ์šฉ (undefined์ผ ๋•Œ๋งŒ ๊ธฐ๋ณธ๊ฐ’ ์ ์šฉ) */} + {processedConfig.text !== undefined ? processedConfig.text : component.label || "๋ฒ„ํŠผ"}
From 76ad3d9c43c3f8193bf5377dbe9bd10fb597fad8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 21 Oct 2025 17:42:31 +0900 Subject: [PATCH 15/21] =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=9C=20=EB=B3=B5=EC=A0=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 68 +++++++++++- .../dashboard/KeyboardShortcutsGuide.tsx | 98 ++++++++++++++++ .../dashboard/hooks/useKeyboardShortcuts.ts | 105 ++++++++++++++++++ 3 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx create mode 100644 frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index c0d08083..4cb5f94d 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -8,11 +8,13 @@ import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; +import { KeyboardShortcutsGuide } from "./KeyboardShortcutsGuide"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { AlertDialog, @@ -25,7 +27,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { CheckCircle2 } from "lucide-react"; +import { CheckCircle2, Keyboard } from "lucide-react"; interface DashboardDesignerProps { dashboardId?: string; @@ -56,6 +58,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [dashboardDescription, setDashboardDescription] = useState(""); const [successModalOpen, setSuccessModalOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const [shortcutsGuideOpen, setShortcutsGuideOpen] = useState(false); + + // ํด๋ฆฝ๋ณด๋“œ (๋ณต์‚ฌ/๋ถ™์—ฌ๋„ฃ๊ธฐ์šฉ) + const [clipboard, setClipboard] = useState(null); // ํ™”๋ฉด ํ•ด์ƒ๋„ ์ž๋™ ๊ฐ์ง€ const [screenResolution] = useState(() => detectScreenResolution()); @@ -289,6 +295,51 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D [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 && !shortcutsGuideOpen, + }); + // ์ „์ฒด ์‚ญ์ œ ํ™•์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ const clearCanvas = useCallback(() => { setClearConfirmOpen(true); @@ -602,6 +653,21 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D + + {/* ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค ๊ฐ€์ด๋“œ ๋ชจ๋‹ฌ */} + setShortcutsGuideOpen(false)} /> + + {/* ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค ๋„์›€๋ง ํ”Œ๋กœํŒ… ๋ฒ„ํŠผ */} +
+ +
); diff --git a/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx b/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx new file mode 100644 index 00000000..5de942b7 --- /dev/null +++ b/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Keyboard } from "lucide-react"; + +interface KeyboardShortcutsGuideProps { + isOpen: boolean; + onClose: () => void; +} + +interface ShortcutItem { + keys: string[]; + description: string; + category: string; +} + +const shortcuts: ShortcutItem[] = [ + // ๊ธฐ๋ณธ ์ž‘์—… + { keys: ["Delete"], description: "์„ ํƒํ•œ ์š”์†Œ ์‚ญ์ œ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, + { keys: ["Ctrl", "C"], description: "์š”์†Œ ๋ณต์‚ฌ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, + { keys: ["Ctrl", "V"], description: "์š”์†Œ ๋ถ™์—ฌ๋„ฃ๊ธฐ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, + + // ์‹คํ–‰ ์ทจ์†Œ/์žฌ์‹คํ–‰ (๊ตฌํ˜„ ์˜ˆ์ •) + { keys: ["Ctrl", "Z"], description: "์‹คํ–‰ ์ทจ์†Œ (๊ตฌํ˜„ ์˜ˆ์ •)", category: "ํŽธ์ง‘" }, + { keys: ["Ctrl", "Shift", "Z"], description: "์žฌ์‹คํ–‰ (๊ตฌํ˜„ ์˜ˆ์ •)", category: "ํŽธ์ง‘" }, +]; + +const KeyBadge = ({ keyName }: { keyName: string }) => ( + + {keyName} + +); + +export function KeyboardShortcutsGuide({ isOpen, onClose }: KeyboardShortcutsGuideProps) { + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ทธ๋ฃนํ™” + const groupedShortcuts = shortcuts.reduce( + (acc, shortcut) => { + if (!acc[shortcut.category]) { + acc[shortcut.category] = []; + } + acc[shortcut.category].push(shortcut); + return acc; + }, + {} as Record, + ); + + // Mac OS ๊ฐ์ง€ + const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0; + + return ( + + + +
+ + ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค +
+ + ๋Œ€์‹œ๋ณด๋“œ ํŽธ์ง‘์„ ๋” ๋น ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋Š” ๋‹จ์ถ•ํ‚ค ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค + {isMac && " (Mac์—์„œ๋Š” Ctrl ๋Œ€์‹  Cmd ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”)"} + +
+ +
+ {Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => ( +
+

{category}

+
+ {categoryShortcuts.map((shortcut, index) => ( +
+ {shortcut.description} +
+ {shortcut.keys.map((key, keyIndex) => ( + + + {keyIndex < shortcut.keys.length - 1 && +} + + ))} +
+
+ ))} +
+
+ ))} +
+ +
+

๐Ÿ’ก ํŒ

+

์ž…๋ ฅ ํ•„๋“œ๋‚˜ ๋ชจ๋‹ฌ์—์„œ๋Š” ๋‹จ์ถ•ํ‚ค๊ฐ€ ์ž๋™์œผ๋กœ ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.

+
+
+
+ ); +} diff --git a/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..d0fab67f --- /dev/null +++ b/frontend/components/admin/dashboard/hooks/useKeyboardShortcuts.ts @@ -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]); +} From 1d0c4fe5034b1f187033beffcbc5ab177f25d359 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Tue, 21 Oct 2025 17:48:24 +0900 Subject: [PATCH 16/21] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=99=95=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=20=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=B0=94=20?= =?UTF-8?q?=EB=88=84=EB=A5=B4=EA=B3=A0=20=EC=9D=B4=EB=8F=99=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/ResponsivePreviewModal.tsx | 9 ++----- frontend/components/screen/ScreenDesigner.tsx | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/frontend/components/screen/ResponsivePreviewModal.tsx b/frontend/components/screen/ResponsivePreviewModal.tsx index d3f80c3c..1e05a86b 100644 --- a/frontend/components/screen/ResponsivePreviewModal.tsx +++ b/frontend/components/screen/ResponsivePreviewModal.tsx @@ -3,7 +3,7 @@ import React, { useState, createContext, useContext } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 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 { ResponsiveLayoutEngine } from "./ResponsiveLayoutEngine"; import { Breakpoint } from "@/types/responsive"; @@ -76,12 +76,7 @@ export const ResponsivePreviewModal: React.FC = ({ -
- ๋ฐ˜์‘ํ˜• ๋ฏธ๋ฆฌ๋ณด๊ธฐ - -
+ ๋ฐ˜์‘ํ˜• ๋ฏธ๋ฆฌ๋ณด๊ธฐ {/* ๋””๋ฐ”์ด์Šค ์„ ํƒ ๋ฒ„ํŠผ๋“ค */}
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 1b9ec180..33d17a31 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -4014,18 +4014,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD ๐Ÿ” {Math.round(zoomLevel * 100)}%
- {/* ์‹ค์ œ ์ž‘์—… ์บ”๋ฒ„์Šค (ํ•ด์ƒ๋„ ํฌ๊ธฐ) - ๋ฐ˜์‘ํ˜• ๊ฐœ์„  + ์คŒ ์ ์šฉ */} + {/* ๐Ÿ”ฅ ์คŒ ์ ์šฉ ์‹œ ์Šคํฌ๋กค ์˜์—ญ ํ™•๋ณด๋ฅผ ์œ„ํ•œ ๋ž˜ํผ */}
+ {/* ์‹ค์ œ ์ž‘์—… ์บ”๋ฒ„์Šค (ํ•ด์ƒ๋„ ํฌ๊ธฐ) - ๋ฐ˜์‘ํ˜• ๊ฐœ์„  + ์คŒ ์ ์šฉ */} +
+
{/* ๐Ÿ”ฅ ์คŒ ๋ž˜ํผ ๋‹ซ๊ธฐ */}
{" "} {/* ๋ฉ”์ธ ์ปจํ…Œ์ด๋„ˆ ๋‹ซ๊ธฐ */} From b62f2ffc10ae18b46a9fcc5a1d4337351fcda5dc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 09:45:47 +0900 Subject: [PATCH 17/21] =?UTF-8?q?api=EA=B4=80=EB=A6=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=AA=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/data-sources/ApiConfig.tsx | 135 ++++++++++++++++-- frontend/lib/api/externalDbConnection.ts | 94 ++++++++++-- 2 files changed, 208 insertions(+), 21 deletions(-) diff --git a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx index 09f45411..2e6616f9 100644 --- a/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/ApiConfig.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ChartDataSource, QueryResult, KeyValuePair } from "../types"; import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; 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 { dataSource: ChartDataSource; @@ -24,6 +26,106 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); + const [apiConnections, setApiConnections] = useState([]); + const [selectedConnectionId, setSelectedConnectionId] = useState(""); + + // ์™ธ๋ถ€ 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 = { + 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[] => { @@ -217,6 +319,30 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps

์™ธ๋ถ€ API์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ์„ค์ •์„ ์ž…๋ ฅํ•˜์„ธ์š”

+ {/* ์™ธ๋ถ€ ์ปค๋„ฅ์…˜ ์„ ํƒ */} + {apiConnections.length > 0 && ( + +
+ + +

์™ธ๋ถ€ ์ปค๋„ฅ์…˜ ๊ด€๋ฆฌ์—์„œ ์ €์žฅํ•œ REST API ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค

+
+
+ )} + {/* API URL */}
@@ -230,13 +356,6 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps />

GET ์š”์ฒญ์„ ๋ณด๋‚ผ API ์—”๋“œํฌ์ธํŠธ

- - {/* HTTP ๋ฉ”์„œ๋“œ (๊ณ ์ •) */} -
- -
GET (๊ณ ์ •)
-

๋ฐ์ดํ„ฐ ์กฐํšŒ๋Š” GET ๋ฉ”์„œ๋“œ๋งŒ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค

-
{/* ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ */} diff --git a/frontend/lib/api/externalDbConnection.ts b/frontend/lib/api/externalDbConnection.ts index aa161af7..257a7a3f 100644 --- a/frontend/lib/api/externalDbConnection.ts +++ b/frontend/lib/api/externalDbConnection.ts @@ -27,6 +27,36 @@ export interface ExternalDbConnection { 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; + 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 { db_type?: string; is_active?: string; @@ -209,7 +239,7 @@ export class ExternalDbConnectionAPI { try { const response = await apiClient.post>( `${this.BASE_PATH}/${connectionId}/test`, - password ? { password } : undefined + password ? { password } : undefined, ); if (!response.data.success) { @@ -220,10 +250,12 @@ export class ExternalDbConnectionAPI { }; } - return response.data.data || { - success: true, - message: response.data.message || "์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - }; + return ( + response.data.data || { + success: true, + message: response.data.message || "์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + } + ); } catch (error) { console.error("์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ ์˜ค๋ฅ˜:", error); @@ -246,9 +278,7 @@ export class ExternalDbConnectionAPI { */ static async getTables(connectionId: number): Promise> { try { - const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables` - ); + const response = await apiClient.get>(`${this.BASE_PATH}/${connectionId}/tables`); return response.data; } catch (error) { console.error("ํ…Œ์ด๋ธ” ๋ชฉ๋ก ์กฐํšŒ ์˜ค๋ฅ˜:", error); @@ -260,7 +290,7 @@ export class ExternalDbConnectionAPI { try { console.log("์ปฌ๋Ÿผ ์ •๋ณด API ์š”์ฒญ:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`); const response = await apiClient.get>( - `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns` + `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`, ); console.log("์ปฌ๋Ÿผ ์ •๋ณด API ์‘๋‹ต:", response.data); return response.data; @@ -273,10 +303,7 @@ export class ExternalDbConnectionAPI { static async executeQuery(connectionId: number, query: string): Promise> { try { console.log("API ์š”์ฒญ:", `${this.BASE_PATH}/${connectionId}/execute`, { query }); - const response = await apiClient.post>( - `${this.BASE_PATH}/${connectionId}/execute`, - { query } - ); + const response = await apiClient.post>(`${this.BASE_PATH}/${connectionId}/execute`, { query }); console.log("API ์‘๋‹ต:", response.data); return response.data; } catch (error) { @@ -284,4 +311,45 @@ export class ExternalDbConnectionAPI { throw error; } } + + /** + * REST API ์—ฐ๊ฒฐ ๋ชฉ๋ก ์กฐํšŒ (์™ธ๋ถ€ ์ปค๋„ฅ์…˜์—์„œ) + */ + static async getApiConnections(filter: { is_active?: string } = {}): Promise { + try { + const params = new URLSearchParams(); + if (filter.is_active) params.append("is_active", filter.is_active); + + const response = await apiClient.get>( + `/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 { + try { + const response = await apiClient.get>(`/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; + } + } } From 0823874ebc450bb41bd94fa81458142c2f826c37 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 10:07:02 +0900 Subject: [PATCH 18/21] =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 21 +--- .../dashboard/KeyboardShortcutsGuide.tsx | 98 ------------------- 2 files changed, 2 insertions(+), 117 deletions(-) delete mode 100644 frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 4cb5f94d..960583ce 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -8,7 +8,6 @@ import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; -import { KeyboardShortcutsGuide } from "./KeyboardShortcutsGuide"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; @@ -27,7 +26,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; -import { CheckCircle2, Keyboard } from "lucide-react"; +import { CheckCircle2 } from "lucide-react"; interface DashboardDesignerProps { dashboardId?: string; @@ -58,7 +57,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [dashboardDescription, setDashboardDescription] = useState(""); const [successModalOpen, setSuccessModalOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); - const [shortcutsGuideOpen, setShortcutsGuideOpen] = useState(false); // ํด๋ฆฝ๋ณด๋“œ (๋ณต์‚ฌ/๋ถ™์—ฌ๋„ฃ๊ธฐ์šฉ) const [clipboard, setClipboard] = useState(null); @@ -337,7 +335,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onDelete: handleDeleteSelected, onCopy: handleCopyElement, onPaste: handlePasteElement, - enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !shortcutsGuideOpen, + enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen, }); // ์ „์ฒด ์‚ญ์ œ ํ™•์ธ ๋ชจ๋‹ฌ ์—ด๊ธฐ @@ -653,21 +651,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D - - {/* ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค ๊ฐ€์ด๋“œ ๋ชจ๋‹ฌ */} - setShortcutsGuideOpen(false)} /> - - {/* ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค ๋„์›€๋ง ํ”Œ๋กœํŒ… ๋ฒ„ํŠผ */} -
- -
); diff --git a/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx b/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx deleted file mode 100644 index 5de942b7..00000000 --- a/frontend/components/admin/dashboard/KeyboardShortcutsGuide.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import React from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { Keyboard } from "lucide-react"; - -interface KeyboardShortcutsGuideProps { - isOpen: boolean; - onClose: () => void; -} - -interface ShortcutItem { - keys: string[]; - description: string; - category: string; -} - -const shortcuts: ShortcutItem[] = [ - // ๊ธฐ๋ณธ ์ž‘์—… - { keys: ["Delete"], description: "์„ ํƒํ•œ ์š”์†Œ ์‚ญ์ œ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, - { keys: ["Ctrl", "C"], description: "์š”์†Œ ๋ณต์‚ฌ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, - { keys: ["Ctrl", "V"], description: "์š”์†Œ ๋ถ™์—ฌ๋„ฃ๊ธฐ", category: "๊ธฐ๋ณธ ์ž‘์—…" }, - - // ์‹คํ–‰ ์ทจ์†Œ/์žฌ์‹คํ–‰ (๊ตฌํ˜„ ์˜ˆ์ •) - { keys: ["Ctrl", "Z"], description: "์‹คํ–‰ ์ทจ์†Œ (๊ตฌํ˜„ ์˜ˆ์ •)", category: "ํŽธ์ง‘" }, - { keys: ["Ctrl", "Shift", "Z"], description: "์žฌ์‹คํ–‰ (๊ตฌํ˜„ ์˜ˆ์ •)", category: "ํŽธ์ง‘" }, -]; - -const KeyBadge = ({ keyName }: { keyName: string }) => ( - - {keyName} - -); - -export function KeyboardShortcutsGuide({ isOpen, onClose }: KeyboardShortcutsGuideProps) { - // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ทธ๋ฃนํ™” - const groupedShortcuts = shortcuts.reduce( - (acc, shortcut) => { - if (!acc[shortcut.category]) { - acc[shortcut.category] = []; - } - acc[shortcut.category].push(shortcut); - return acc; - }, - {} as Record, - ); - - // Mac OS ๊ฐ์ง€ - const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0; - - return ( - - - -
- - ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค -
- - ๋Œ€์‹œ๋ณด๋“œ ํŽธ์ง‘์„ ๋” ๋น ๋ฅด๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋Š” ๋‹จ์ถ•ํ‚ค ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค - {isMac && " (Mac์—์„œ๋Š” Ctrl ๋Œ€์‹  Cmd ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”)"} - -
- -
- {Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => ( -
-

{category}

-
- {categoryShortcuts.map((shortcut, index) => ( -
- {shortcut.description} -
- {shortcut.keys.map((key, keyIndex) => ( - - - {keyIndex < shortcut.keys.length - 1 && +} - - ))} -
-
- ))} -
-
- ))} -
- -
-

๐Ÿ’ก ํŒ

-

์ž…๋ ฅ ํ•„๋“œ๋‚˜ ๋ชจ๋‹ฌ์—์„œ๋Š” ๋‹จ์ถ•ํ‚ค๊ฐ€ ์ž๋™์œผ๋กœ ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.

-
-
-
- ); -} From 63553e23b1afdb1ff57110dc989227a36358a632 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 22 Oct 2025 10:10:21 +0900 Subject: [PATCH 19/21] =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EC=9C=84=EC=A0=AF=20=EC=84=A0=ED=83=9D=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 109 +++++++- .../admin/dashboard/DashboardCanvas.tsx | 252 +++++++++++++++++- .../admin/dashboard/DashboardDesigner.tsx | 12 +- 3 files changed, 363 insertions(+), 10 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index a4b0fc6f..838a96f8 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -129,10 +129,17 @@ const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/C interface CanvasElementProps { element: DashboardElement; isSelected: boolean; + selectedElements?: string[]; // ๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ๋œ ์š”์†Œ ID ๋ฐฐ์—ด + allElements?: DashboardElement[]; // ๐Ÿ”ฅ ๋ชจ๋“  ์š”์†Œ ๋ฐฐ์—ด + multiDragOffset?: { x: number; y: number }; // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์‹œ ์ด ์š”์†Œ์˜ ์˜คํ”„์…‹ cellSize: number; subGridSize: number; canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; + onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // ๐Ÿ”ฅ ๋‹ค์ค‘ ์—…๋ฐ์ดํŠธ + onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void; // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ + onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ค‘ + onMultiDragEnd?: () => void; // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ onRemove: (id: string) => void; onSelect: (id: string | null) => void; onConfigure?: (element: DashboardElement) => void; @@ -147,10 +154,17 @@ interface CanvasElementProps { export function CanvasElement({ element, isSelected, + selectedElements = [], + allElements = [], + multiDragOffset, cellSize, subGridSize, canvasWidth = 1560, onUpdate, + onUpdateMultiple, + onMultiDragStart, + onMultiDragMove, + onMultiDragEnd, onRemove, onSelect, onConfigure, @@ -205,9 +219,27 @@ export function CanvasElement({ elementX: element.position.x, elementY: element.position.y, }); + + // ๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ๋œ ๊ฒฝ์šฐ, ๋‹ค๋ฅธ ์œ„์ ฏ๋“ค์˜ ์˜คํ”„์…‹ ๊ณ„์‚ฐ + if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) { + const offsets: Record = {}; + 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(); }, - [element.id, element.position.x, element.position.y, onSelect, isSelected], + [element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart], ); // ๋ฆฌ์‚ฌ์ด์ฆˆ ํ•ธ๋“ค ๋งˆ์šฐ์Šค๋‹ค์šด @@ -263,6 +295,11 @@ export function CanvasElement({ const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; setTempPosition({ x: snappedX, y: snappedY }); + + // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ค‘ - ๋‹ค๋ฅธ ์œ„์ ฏ๋“ค์˜ ์œ„์น˜ ์—…๋ฐ์ดํŠธ + if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { + onMultiDragMove(element, { x: snappedX, y: snappedY }); + } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; const deltaY = e.clientY - resizeStart.y; @@ -345,12 +382,14 @@ export function CanvasElement({ isResizing, dragStart, resizeStart, - element.size.width, - element.type, - element.subtype, + element, canvasWidth, cellSize, subGridSize, + selectedElements, + allElements, + onUpdateMultiple, + onMultiDragMove, ], ); @@ -370,7 +409,43 @@ export function CanvasElement({ 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), + z: targetElement.position.z, + }, + }, + }; + }) + .filter((update): update is { id: string; updates: Partial } => update !== null); + + if (updates.length > 0) { + console.log("๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ ์š”์†Œ ํ•จ๊ป˜ ์ด๋™:", updates); + onUpdateMultiple(updates); + } + } + setTempPosition(null); + + // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ + if (onMultiDragEnd) { + onMultiDragEnd(); + } } if (isResizing && tempPosition && tempSize) { @@ -396,7 +471,23 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]); + }, [ + isDragging, + isResizing, + tempPosition, + tempSize, + element.id, + element.size.width, + onUpdate, + onUpdateMultiple, + onMultiDragEnd, + cellSize, + canvasWidth, + selectedElements, + allElements, + dragStart.elementX, + dragStart.elementY, + ]); // ์ „์—ญ ๋งˆ์šฐ์Šค ์ด๋ฒคํŠธ ๋“ฑ๋ก React.useEffect(() => { @@ -525,12 +616,18 @@ 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, + z: element.position.z, + } : element.position); const displaySize = tempSize || element.size; return (
void; onUpdateElement: (id: string, updates: Partial) => void; onRemoveElement: (id: string) => void; onSelectElement: (id: string | null) => void; + onSelectMultiple?: (ids: string[]) => void; // ๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ ํ•ธ๋“ค๋Ÿฌ onConfigureElement?: (element: DashboardElement) => void; backgroundColor?: string; canvasWidth?: number; @@ -31,10 +33,12 @@ export const DashboardCanvas = forwardRef( { elements, selectedElement, + selectedElements = [], onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, + onSelectMultiple, onConfigureElement, backgroundColor = "#f9fafb", canvasWidth = 1560, @@ -43,6 +47,19 @@ export const DashboardCanvas = forwardRef( ref, ) => { 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 [multiDragOffsets, setMultiDragOffsets] = useState>({}); // ํ˜„์žฌ ์บ”๋ฒ„์Šค ํฌ๊ธฐ์— ๋งž๋Š” ๊ทธ๋ฆฌ๋“œ ์„ค์ • ๊ณ„์‚ฐ const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); @@ -182,14 +199,174 @@ export const DashboardCanvas = forwardRef( [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); + + 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]); + // ์บ”๋ฒ„์Šค ํด๋ฆญ ์‹œ ์„ ํƒ ํ•ด์ œ const handleCanvasClick = useCallback( (e: React.MouseEvent) => { + // ๐Ÿ”ฅ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ์œผ๋ฉด ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ (์„ ํƒ ํ•ด์ œ ๋ฐฉ์ง€) + if (justSelected) { + console.log("๐Ÿšซ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ์œผ๋ฏ€๋กœ ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ"); + return; + } + if (e.target === e.currentTarget) { + console.log("โœ… ๋นˆ ๊ณต๊ฐ„ ํด๋ฆญ - ์„ ํƒ ํ•ด์ œ"); onSelectElement(null); + if (onSelectMultiple) { + onSelectMultiple([]); + } } }, - [onSelectElement], + [onSelectElement, onSelectMultiple, justSelected], ); // ๋™์  ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ ๊ณ„์‚ฐ @@ -202,6 +379,23 @@ export const DashboardCanvas = forwardRef( // 12๊ฐœ ์ปฌ๋Ÿผ ๊ตฌ๋ถ„์„  ์œ„์น˜ ๊ณ„์‚ฐ 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 (
( backgroundSize: `${subGridSize}px ${subGridSize}px`, backgroundPosition: "0 0", backgroundRepeat: "repeat", + cursor: isSelecting ? "crosshair" : "default", }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} onClick={handleCanvasClick} + onMouseDown={handleMouseDown} > {/* 12๊ฐœ ์ปฌ๋Ÿผ ๋ฉ”์ธ ๊ตฌ๋ถ„์„  */} {columnLines.map((x, i) => ( @@ -249,16 +445,66 @@ export const DashboardCanvas = forwardRef( { + // ๐Ÿ”ฅ ์—ฌ๋Ÿฌ ์š”์†Œ ๋™์‹œ ์—…๋ฐ์ดํŠธ (์ถฉ๋Œ ๊ฐ์ง€ ๊ฑด๋„ˆ๋›ฐ๊ธฐ) + updates.forEach(({ id, updates: elementUpdates }) => { + onUpdateElement(id, elementUpdates); + }); + }} + onMultiDragStart={(draggedId, initialOffsets) => { + // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ - ์ดˆ๊ธฐ ์˜คํ”„์…‹ ์ €์žฅ + setMultiDragOffsets(initialOffsets); + }} + onMultiDragMove={(draggedElement, tempPosition) => { + // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ค‘ - ๋‹ค๋ฅธ ์œ„์ ฏ๋“ค์˜ ์œ„์น˜ ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ + if (selectedElements.length > 1 && selectedElements.includes(draggedElement.id)) { + const newOffsets: Record = {}; + 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({}); + }} onRemove={onRemoveElement} onSelect={onSelectElement} onConfigure={onConfigureElement} /> ))} + + {/* ๐Ÿ”ฅ ์„ ํƒ ๋ฐ•์Šค ๋ Œ๋”๋ง */} + {selectionBox && selectionBoxStyle && ( +
+ )}
); }, diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index c0d08083..4f504c1f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -43,6 +43,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { refreshMenus } = useMenu(); const [elements, setElements] = useState([]); const [selectedElement, setSelectedElement] = useState(null); + const [selectedElements, setSelectedElements] = useState([]); // ๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ const [elementCounter, setElementCounter] = useState(0); const [configModalElement, setConfigModalElement] = useState(null); const [dashboardId, setDashboardId] = useState(initialDashboardId || null); @@ -504,10 +505,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D ref={canvasRef} elements={elements} selectedElement={selectedElement} + selectedElements={selectedElements} onCreateElement={createElement} onUpdateElement={updateElement} onRemoveElement={removeElement} - onSelectElement={setSelectedElement} + onSelectElement={(id) => { + setSelectedElement(id); + setSelectedElements([]); // ๋‹จ์ผ ์„ ํƒ ์‹œ ๋‹ค์ค‘ ์„ ํƒ ํ•ด์ œ + }} + onSelectMultiple={(ids) => { + console.log("๐ŸŽฏ DashboardDesigner - onSelectMultiple ํ˜ธ์ถœ:", ids); + setSelectedElements(ids); + setSelectedElement(null); // ๋‹ค์ค‘ ์„ ํƒ ์‹œ ๋‹จ์ผ ์„ ํƒ ํ•ด์ œ + }} onConfigureElement={openConfigModal} backgroundColor={canvasBackgroundColor} canvasWidth={canvasConfig.width} From 01f92d6132fc9bf830610b384e0261ddacafe4da Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 22 Oct 2025 10:13:59 +0900 Subject: [PATCH 20/21] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 50 +++---------------- .../admin/dashboard/DashboardCanvas.tsx | 6 +-- 2 files changed, 11 insertions(+), 45 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 838a96f8..7bd4165e 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -280,19 +280,9 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // ๋“œ๋ž˜๊ทธ ์ค‘ ์‹ค์‹œ๊ฐ„ ์Šค๋ƒ… (๋งˆ๊ทธ๋„คํ‹ฑ ์Šค๋ƒ…) - const gridSize = cellSize + 5; // GAP ํฌํ•จํ•œ ์‹ค์ œ ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ - const magneticThreshold = 15; // ํฐ ๊ทธ๋ฆฌ๋“œ์— ๋Œ๋ฆฌ๋Š” ๊ฑฐ๋ฆฌ (px) - - // 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; + // ๋“œ๋ž˜๊ทธ ์ค‘ ์‹ค์‹œ๊ฐ„ ์Šค๋ƒ… (์„œ๋ธŒ๊ทธ๋ฆฌ๋“œ๋งŒ ์‚ฌ์šฉ) + const snappedX = Math.round(rawX / subGridSize) * subGridSize; + const snappedY = Math.round(rawY / subGridSize) * subGridSize; setTempPosition({ x: snappedX, y: snappedY }); @@ -342,35 +332,11 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // ๋ฆฌ์‚ฌ์ด์ฆˆ ์ค‘ ์‹ค์‹œ๊ฐ„ ์Šค๋ƒ… (๋งˆ๊ทธ๋„คํ‹ฑ ์Šค๋ƒ…) - const gridSize = cellSize + 5; // GAP ํฌํ•จํ•œ ์‹ค์ œ ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ - const magneticThreshold = 15; - - // ์œ„์น˜ ์Šค๋ƒ… - 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; + // ๋ฆฌ์‚ฌ์ด์ฆˆ ์ค‘ ์‹ค์‹œ๊ฐ„ ์Šค๋ƒ… (์„œ๋ธŒ๊ทธ๋ฆฌ๋“œ๋งŒ ์‚ฌ์šฉ) + const snappedX = Math.round(newX / subGridSize) * subGridSize; + const snappedY = Math.round(newY / subGridSize) * subGridSize; + const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize; + const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize; // ์ž„์‹œ ํฌ๊ธฐ/์œ„์น˜ ์ €์žฅ (์Šค๋ƒ…๋จ) setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 073ddb37..f586df57 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -420,8 +420,8 @@ export const DashboardCanvas = forwardRef( onClick={handleCanvasClick} onMouseDown={handleMouseDown} > - {/* 12๊ฐœ ์ปฌ๋Ÿผ ๋ฉ”์ธ ๊ตฌ๋ถ„์„  */} - {columnLines.map((x, i) => ( + {/* 12๊ฐœ ์ปฌ๋Ÿผ ๋ฉ”์ธ ๊ตฌ๋ถ„์„  - ์ฃผ์„ ์ฒ˜๋ฆฌ (์„œ๋ธŒ๊ทธ๋ฆฌ๋“œ๋งŒ ์‚ฌ์šฉ) */} + {/* {columnLines.map((x, i) => (
( zIndex: 0, }} /> - ))} + ))} */} {/* ๋ฐฐ์น˜๋œ ์š”์†Œ๋“ค ๋ Œ๋”๋ง */} {elements.length === 0 && (
From fc0bc3e5c8f6052b18073e78d6ab99e768f4ee85 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 22 Oct 2025 11:23:38 +0900 Subject: [PATCH 21/21] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4,=20=EB=8B=A4=EC=A4=91=EC=84=A0=ED=83=9D=20=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=ED=9C=A0=EB=A1=9C=20=EC=9C=84=EC=95=84=EB=9E=98=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 101 +++++++++++++++-- .../admin/dashboard/DashboardCanvas.tsx | 103 ++++++++++++++---- .../admin/dashboard/DashboardDesigner.tsx | 24 ++-- 3 files changed, 189 insertions(+), 39 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 7bd4165e..55d45480 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -172,6 +172,10 @@ export function CanvasElement({ const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); 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(null); // ๐Ÿ”ฅ requestAnimationFrame ID + const lastMouseYRef = useRef(window.innerHeight / 2); // ๐Ÿ”ฅ ๋งˆ์ง€๋ง‰ ๋งˆ์šฐ์Šค Y ์œ„์น˜ (์ดˆ๊ธฐ๊ฐ’: ํ™”๋ฉด ์ค‘๊ฐ„) const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, @@ -213,12 +217,18 @@ export function CanvasElement({ } setIsDragging(true); - setDragStart({ + const startPos = { x: e.clientX, y: e.clientY, elementX: element.position.x, 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) { @@ -269,8 +279,25 @@ export function CanvasElement({ const handleMouseMove = useCallback( (e: MouseEvent) => { 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); @@ -356,6 +383,7 @@ export function CanvasElement({ allElements, onUpdateMultiple, onMultiDragMove, + // dragStartRef, autoScrollDirectionRef, autoScrollFrameRef๋Š” ref๋ผ์„œ dependency ๋ถˆํ•„์š” ], ); @@ -393,7 +421,6 @@ export function CanvasElement({ position: { x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)), y: Math.max(0, finalY + relativeY), - z: targetElement.position.z, }, }, }; @@ -401,7 +428,7 @@ export function CanvasElement({ .filter((update): update is { id: string; updates: Partial } => update !== null); if (updates.length > 0) { - console.log("๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ ์š”์†Œ ํ•จ๊ป˜ ์ด๋™:", updates); + // console.log("๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ ์š”์†Œ ํ•จ๊ป˜ ์ด๋™:", updates); onUpdateMultiple(updates); } } @@ -437,6 +464,13 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); + + // ๐Ÿ”ฅ ์ž๋™ ์Šคํฌ๋กค ์ •๋ฆฌ + autoScrollDirectionRef.current = null; + if (autoScrollFrameRef.current) { + cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + } }, [ isDragging, isResizing, @@ -455,6 +489,60 @@ export function CanvasElement({ 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(() => { if (isDragging || isResizing) { @@ -586,7 +674,6 @@ export function CanvasElement({ const displayPosition = tempPosition || (multiDragOffset && !isDragging ? { x: element.position.x + multiDragOffset.x, y: element.position.y + multiDragOffset.y, - z: element.position.z, } : element.position); const displaySize = tempSize || element.size; diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index f586df57..1c2414c1 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -57,9 +57,14 @@ export const DashboardCanvas = forwardRef( } | null>(null); const [isSelecting, setIsSelecting] = useState(false); const [justSelected, setJustSelected] = useState(false); // ๐Ÿ”ฅ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ๋Š”์ง€ ํ”Œ๋ž˜๊ทธ + const [isDraggingAny, setIsDraggingAny] = useState(false); // ๐Ÿ”ฅ ํ˜„์žฌ ๋“œ๋ž˜๊ทธ ์ค‘์ธ์ง€ ํ”Œ๋ž˜๊ทธ // ๐Ÿ”ฅ ๋‹ค์ค‘ ์„ ํƒ๋œ ์œ„์ ฏ๋“ค์˜ ์ž„์‹œ ์œ„์น˜ (๋“œ๋ž˜๊ทธ ์ค‘ ์‹œ๊ฐ์  ํ”ผ๋“œ๋ฐฑ) const [multiDragOffsets, setMultiDragOffsets] = useState>({}); + + // ๐Ÿ”ฅ ์„ ํƒ ๋ฐ•์Šค ๋“œ๋ž˜๊ทธ ์ค‘ ์ž๋™ ์Šคํฌ๋กค + const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2); + const selectionAutoScrollFrameRef = React.useRef(null); // ํ˜„์žฌ ์บ”๋ฒ„์Šค ํฌ๊ธฐ์— ๋งž๋Š” ๊ทธ๋ฆฌ๋“œ ์„ค์ • ๊ณ„์‚ฐ const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); @@ -207,11 +212,11 @@ export const DashboardCanvas = forwardRef( const isWidget = target.closest("[data-element-id]"); if (isWidget) { - console.log("๐Ÿšซ ์œ„์ ฏ ๋‚ด๋ถ€ ํด๋ฆญ - ์„ ํƒ ๋ฐ•์Šค ์‹œ์ž‘ ์•ˆํ•จ"); + // console.log("๐Ÿšซ ์œ„์ ฏ ๋‚ด๋ถ€ ํด๋ฆญ - ์„ ํƒ ๋ฐ•์Šค ์‹œ์ž‘ ์•ˆํ•จ"); return; } - console.log("โœ… ๋นˆ ๊ณต๊ฐ„ ํด๋ฆญ - ์„ ํƒ ๋ฐ•์Šค ์‹œ์ž‘"); + // console.log("โœ… ๋นˆ ๊ณต๊ฐ„ ํด๋ฆญ - ์„ ํƒ ๋ฐ•์Šค ์‹œ์ž‘"); if (!ref || typeof ref === "function") return; const rect = ref.current?.getBoundingClientRect(); @@ -246,7 +251,7 @@ export const DashboardCanvas = forwardRef( const minY = Math.min(selectionBox.startY, selectionBox.endY); const maxY = Math.max(selectionBox.startY, selectionBox.endY); - console.log("๐Ÿ” ์„ ํƒ ๋ฐ•์Šค:", { minX, maxX, minY, maxY }); + // console.log("๐Ÿ” ์„ ํƒ ๋ฐ•์Šค:", { minX, maxX, minY, maxY }); // ์„ ํƒ ๋ฐ•์Šค ์•ˆ์— ์žˆ๋Š” ์š”์†Œ๋“ค ์ฐพ๊ธฐ (70% ์ด์ƒ ๊ฒน์น˜๋ฉด ์„ ํƒ) const selectedIds = elements @@ -276,18 +281,18 @@ export const DashboardCanvas = forwardRef( // 70% ์ด์ƒ ๊ฒน์น˜๋ฉด ์„ ํƒ const overlapPercentage = overlapArea / elementArea; - console.log(`๐Ÿ“ฆ ์š”์†Œ ${el.id}:`, { - position: el.position, - size: el.size, - overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%", - selected: overlapPercentage >= 0.7, - }); + // 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); + // console.log("โœ… ์„ ํƒ๋œ ์š”์†Œ:", selectedIds); if (selectedIds.length > 0) { onSelectMultiple(selectedIds); @@ -313,30 +318,33 @@ export const DashboardCanvas = forwardRef( const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); - console.log("๐Ÿ–ฑ๏ธ ๋งˆ์šฐ์Šค ์ด๋™:", { x, y, startX: selectionBox.startX, startY: selectionBox.startY, isSelecting }); + // ๐Ÿ”ฅ ์ž๋™ ์Šคํฌ๋กค์„ ์œ„ํ•œ ๋งˆ์šฐ์Šค 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 }); + // console.log("๐Ÿ“ ์ด๋™ ๊ฑฐ๋ฆฌ:", { deltaX, deltaY }); // ๐Ÿ”ฅ 5px ์ด์ƒ ์›€์ง์ด๋ฉด ์„ ํƒ ๋ฐ•์Šค ํ™œ์„ฑํ™” (์œ„์ ฏ ๋“œ๋ž˜๊ทธ์™€ ๊ตฌ๋ถ„) if (deltaX > 5 || deltaY > 5) { - console.log("๐ŸŽฏ ์„ ํƒ ๋ฐ•์Šค ํ™œ์„ฑํ™” (5px ์ด์ƒ ์ด๋™)"); + // console.log("๐ŸŽฏ ์„ ํƒ ๋ฐ•์Šค ํ™œ์„ฑํ™” (5px ์ด์ƒ ์ด๋™)"); setIsSelecting(true); } return; } // ๐Ÿ”ฅ ์„ ํƒ ๋ฐ•์Šค ์—…๋ฐ์ดํŠธ - console.log("๐Ÿ“ฆ ์„ ํƒ ๋ฐ•์Šค ์—…๋ฐ์ดํŠธ:", { startX: selectionBox.startX, startY: selectionBox.startY, endX: x, endY: y }); + // 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 ํ˜ธ์ถœ"); + // console.log("๐Ÿ–ฑ๏ธ ๋งˆ์šฐ์Šค ์—… - handleMouseUp ํ˜ธ์ถœ"); handleMouseUp(); }; @@ -349,24 +357,77 @@ export const DashboardCanvas = forwardRef( }; }, [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( (e: React.MouseEvent) => { - // ๐Ÿ”ฅ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ์œผ๋ฉด ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ (์„ ํƒ ํ•ด์ œ ๋ฐฉ์ง€) - if (justSelected) { - console.log("๐Ÿšซ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ์œผ๋ฏ€๋กœ ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ"); + // ๐Ÿ”ฅ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ๊ฑฐ๋‚˜ ๋“œ๋ž˜๊ทธ ์ค‘์ด๋ฉด ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ (์„ ํƒ ํ•ด์ œ ๋ฐฉ์ง€) + if (justSelected || isDraggingAny) { + // console.log("๐Ÿšซ ๋ฐฉ๊ธˆ ์„ ํƒํ–ˆ๊ฑฐ๋‚˜ ๋“œ๋ž˜๊ทธ ์ค‘์ด๋ฏ€๋กœ ํด๋ฆญ ์ด๋ฒคํŠธ ๋ฌด์‹œ"); return; } if (e.target === e.currentTarget) { - console.log("โœ… ๋นˆ ๊ณต๊ฐ„ ํด๋ฆญ - ์„ ํƒ ํ•ด์ œ"); + // console.log("โœ… ๋นˆ ๊ณต๊ฐ„ ํด๋ฆญ - ์„ ํƒ ํ•ด์ œ"); onSelectElement(null); if (onSelectMultiple) { onSelectMultiple([]); } } }, - [onSelectElement, onSelectMultiple, justSelected], + [onSelectElement, onSelectMultiple, justSelected, isDraggingAny], ); // ๋™์  ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ ๊ณ„์‚ฐ @@ -462,6 +523,7 @@ export const DashboardCanvas = forwardRef( onMultiDragStart={(draggedId, initialOffsets) => { // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์‹œ์ž‘ - ์ดˆ๊ธฐ ์˜คํ”„์…‹ ์ €์žฅ setMultiDragOffsets(initialOffsets); + setIsDraggingAny(true); }} onMultiDragMove={(draggedElement, tempPosition) => { // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ค‘ - ๋‹ค๋ฅธ ์œ„์ ฏ๋“ค์˜ ์œ„์น˜ ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ @@ -486,6 +548,7 @@ export const DashboardCanvas = forwardRef( onMultiDragEnd={() => { // ๐Ÿ”ฅ ๋‹ค์ค‘ ๋“œ๋ž˜๊ทธ ์ข…๋ฃŒ - ์˜คํ”„์…‹ ์ดˆ๊ธฐํ™” setMultiDragOffsets({}); + setIsDraggingAny(false); }} onRemove={onRemoveElement} onSelect={onSelectElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index a73e6a47..2d482ab3 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -9,7 +9,7 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; 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 { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -214,22 +214,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D 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") { - defaultCells = { width: 4, height: 3 }; // ์ฐจํŠธ + defaultWidth = subGridSize * 20; // ์ฐจํŠธ: ์„œ๋ธŒ๊ทธ๋ฆฌ๋“œ 20์นธ + defaultHeight = subGridSize * 15; // ์ฐจํŠธ: ์„œ๋ธŒ๊ทธ๋ฆฌ๋“œ 15์นธ } 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) { // console.error("Invalid size calculated:", {